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:
69
docs/producto/api-terceros.md
Normal file
69
docs/producto/api-terceros.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# API de terceros gestionable (claves de API)
|
||||||
|
|
||||||
|
La API pública `/skeen/api/v1/*` (consumida por Hermes) se autentica con el
|
||||||
|
método `auth='api_key'`, implementado en
|
||||||
|
`skeen_plataforma/models/ir_http.py`. Acepta dos tipos de credencial:
|
||||||
|
|
||||||
|
1. **Clave legacy de Hermes** — `ir.config_parameter 'skeen.api.legacy_key'`,
|
||||||
|
sembrada con el valor histórico `skeen_api_key_2026`. Tiene todos los
|
||||||
|
alcances (compatibilidad total).
|
||||||
|
2. **Claves del modelo `skeen.api.key`** — con alcances (*scopes*), vencimiento
|
||||||
|
y revocación.
|
||||||
|
|
||||||
|
## Formato de autenticación
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <clave>
|
||||||
|
X-API-Key: <clave>
|
||||||
|
?api_key=<clave> (query/form)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modelo `skeen.api.key`
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` | Nombre de la clave |
|
||||||
|
| `prefijo` | Prefijo visible (los primeros 14 caracteres) |
|
||||||
|
| `hash` | SHA-256 de la clave (nunca se guarda en claro) |
|
||||||
|
| `activo` | Habilitada |
|
||||||
|
| `scopes` | Lista JSON de alcances; vacío = todos |
|
||||||
|
| `vence` | Fecha de vencimiento (opcional) |
|
||||||
|
| `ultimo_uso`, `usos` | Uso registrado en cada autenticación |
|
||||||
|
| `notas`, `creado_por_login` | Metadatos |
|
||||||
|
| `revocada_at`, `revocada_por` | Revocación |
|
||||||
|
|
||||||
|
La clave **se muestra una sola vez** al crearla (solo se guarda el hash; la
|
||||||
|
comparación es en tiempo constante).
|
||||||
|
|
||||||
|
## Alcances por endpoint
|
||||||
|
|
||||||
|
| Endpoint | Alcance |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /skeen/api/v1/available_slots` | `citas.read` |
|
||||||
|
| `POST /skeen/api/v1/create_appointment` | `citas.write` |
|
||||||
|
| `POST /skeen/api/v1/cancel_appointment` | `citas.write` |
|
||||||
|
| `POST /skeen/api/v1/patient_balance` | `pacientes.read` |
|
||||||
|
| `POST /skeen/api/v1/wallet_redeem` | `monedero.write` |
|
||||||
|
| `GET/POST /skeen/api/v1/services` | `servicios.read` |
|
||||||
|
|
||||||
|
Una clave sin el alcance recibe **HTTP 403** con mensaje claro. La clave legacy
|
||||||
|
no tiene restricción de alcance.
|
||||||
|
|
||||||
|
Las respuestas son **JSON plano** (no JSON-RPC): códigos 200 correctos,
|
||||||
|
`401/403` para clave ausente/inválida o sin alcance.
|
||||||
|
|
||||||
|
## Endpoints de gestión
|
||||||
|
|
||||||
|
| Método | Ruta | Acceso |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/skeen/frontend/v1/api_keys` | permiso `api.claves` (admin) |
|
||||||
|
| POST | `/skeen/frontend/v1/api_keys` | permiso `api.claves` (admin) |
|
||||||
|
| PUT | `/skeen/frontend/v1/api_keys/<id>` | permiso `api.claves` (admin) |
|
||||||
|
| POST | `/skeen/frontend/v1/api_keys/<id>/revocar` | permiso `api.claves` (admin) |
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Configuración → pestaña **API** (admin): lista de claves (nombre, prefijo,
|
||||||
|
alcances, último uso, usos, estado), botón "nueva clave" con modal que muestra
|
||||||
|
la clave una vez y botón de copiar, editar y revocar; y documentación breve de
|
||||||
|
los endpoints y del formato de autenticación.
|
||||||
118
docs/producto/licencias.md
Normal file
118
docs/producto/licencias.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Licenciamiento por plan
|
||||||
|
|
||||||
|
Producto comercial: cada instancia white-label puede llevar una **licencia
|
||||||
|
firmada** que decide qué módulos incluye y sus límites de uso. Sin licencia
|
||||||
|
aplicada (el default) el sistema se comporta **exactamente como hoy**.
|
||||||
|
|
||||||
|
## Bloque `licencia` de clinic-settings
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plan": "custom",
|
||||||
|
"modo": "aviso",
|
||||||
|
"cliente": "",
|
||||||
|
"vence": "",
|
||||||
|
"clave": "",
|
||||||
|
"modulos": [],
|
||||||
|
"limites": {
|
||||||
|
"usuarios_max": 0,
|
||||||
|
"sucursales_max": 0,
|
||||||
|
"citas_mes_max": 0,
|
||||||
|
"almacenamiento_mb": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `plan`: `basico | profesional | enterprise | custom`.
|
||||||
|
* `modo`: `aviso` (default; **solo advierte**) o `bloqueo` (aplica de verdad).
|
||||||
|
* `limites`: `0` = **sin límite** (default → nada cambia).
|
||||||
|
* `modulos`: lista de módulos incluidos; **vacío = todos**.
|
||||||
|
* `clave`: la clave firmada por el vendedor. Vacía = sin licencia aplicada.
|
||||||
|
|
||||||
|
La licencia **nunca** bloquea lo indispensable: login, agenda, pacientes, POS,
|
||||||
|
inventario, reportes y configuración siempre están disponibles. Solo aplica a
|
||||||
|
los *módulos gateables* y a los límites indicados.
|
||||||
|
|
||||||
|
## Tabla de planes
|
||||||
|
|
||||||
|
| Plan | Módulos incluidos |
|
||||||
|
| --- | --- |
|
||||||
|
| `basico` | `agenda`, `pacientes`, `pos`, `inventario`, `reportes` (sin whatsapp, notificaciones, reservas, recursos, membresías, compras, comisiones, formularios) |
|
||||||
|
| `profesional` | Básico **+** `whatsapp`, `notificaciones`, `reservas_online`, `recursos` |
|
||||||
|
| `enterprise` | Todos los módulos (los 5 base + los 8 gateables) |
|
||||||
|
| `custom` | Lo que diga la clave (si no lista módulos, todos) |
|
||||||
|
|
||||||
|
Módulos *gateables* (controlables por licencia): `whatsapp`, `notificaciones`,
|
||||||
|
`reservas_online`, `recursos`, `membresias`, `compras`, `comisiones`,
|
||||||
|
`formularios`.
|
||||||
|
|
||||||
|
## Aplicación con `modo=bloqueo`
|
||||||
|
|
||||||
|
* **Módulos no incluidos**: sus secciones desaparecen del menú, sus endpoints
|
||||||
|
responden **403** con un mensaje claro (`Módulo no incluido en tu plan`) y su
|
||||||
|
bloque `activo` de clinic-settings se considera apagado.
|
||||||
|
* **Límites**: se impide crear más allá de `usuarios_max`, `sucursales_max`,
|
||||||
|
`citas_mes_max` (citas del mes en curso) y `almacenamiento_mb` (suma de
|
||||||
|
adjuntos). El error indica el límite y cómo ampliarlo.
|
||||||
|
* Con `modo=aviso` nada se bloquea; los excesos solo quedan advertidos.
|
||||||
|
|
||||||
|
Las tareas internas (cron, migraciones) pueden saltarse los límites con el
|
||||||
|
contexto `skeen_licencia_skip=True`.
|
||||||
|
|
||||||
|
## Formato de la clave de licencia
|
||||||
|
|
||||||
|
Texto en una línea:
|
||||||
|
|
||||||
|
```
|
||||||
|
SKN1.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor_key))>
|
||||||
|
```
|
||||||
|
|
||||||
|
* `payload_json` = JSON con claves ordenadas y separadores mínimos:
|
||||||
|
`cliente`, `plan`, `vence`, `emitida`, `modulos`, `limites`.
|
||||||
|
* Firmado con **HMAC-SHA256** y la clave del vendedor.
|
||||||
|
|
||||||
|
### Clave del vendedor
|
||||||
|
|
||||||
|
Debe estar presente en la instancia para poder validar (HMAC es simétrico):
|
||||||
|
|
||||||
|
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`)
|
||||||
|
|
||||||
|
> **Ruta del secreto:** `/root/.skeen-licencias/vendor.key` (fuera del repo, 600).
|
||||||
|
> Nota de seguridad: al ser HMAC simétrico, quien tenga la clave del vendedor
|
||||||
|
> puede forjar licencias; mantenerla fuera de las instancias de cliente en un
|
||||||
|
> despliegue real (aquí vive en un VPS de desarrollo compartido).
|
||||||
|
|
||||||
|
### Herramienta del vendedor
|
||||||
|
|
||||||
|
`/root/skeen-proyecto/infra/licencias/generar_licencia.py` (CLI, Python stdlib):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
generar_licencia.py generar --cliente "Spa Bella" --plan profesional --vence 2027-12-31
|
||||||
|
generar_licencia.py generar --cliente "Spa Bella" --plan custom --vence 2027-12-31 \
|
||||||
|
--modulos agenda,pacientes,pos,inventario,reportes,compras \
|
||||||
|
--usuarios-max 10 --sucursales-max 2 --citas-mes-max 500 --almacenamiento-mb 4096
|
||||||
|
generar_licencia.py verificar --clave "SKN1...."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Método | Ruta | Acceso | Descripción |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/skeen/frontend/v1/licencia` | admin/gerencia | Estado completo + uso vs límites |
|
||||||
|
| PUT | `/skeen/frontend/v1/licencia` | admin | Aplicar/actualizar/quitar licencia |
|
||||||
|
| POST | `/skeen/frontend/v1/licencia/validar` | admin/gerencia | Probar una clave sin aplicarla |
|
||||||
|
|
||||||
|
`PUT` con `{"clave": "SKN1..."}` verifica firma y vigencia; si es inválida o
|
||||||
|
vencida responde **400** y **no sobrescribe** la licencia previa. `{"clave": ""}`
|
||||||
|
quita la licencia y vuelve a habilitarlo todo. `{"modo": "bloqueo"}` cambia el
|
||||||
|
modo sin re-firmar.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Configuración → pestaña **Licencia** (admin; gerencia también ve el estado):
|
||||||
|
plan y cliente, vencimiento con días restantes, campo para pegar la clave,
|
||||||
|
tabla de uso vs límites con semáforo, tabla de módulos incluidos/no incluidos,
|
||||||
|
avisos de vencimiento (≤ 15 días) y, si no hay licencia, el mensaje
|
||||||
|
*"sin licencia aplicada — todas las funciones habilitadas"*.
|
||||||
112
docs/producto/webhooks.md
Normal file
112
docs/producto/webhooks.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Webhooks salientes
|
||||||
|
|
||||||
|
Notificación de eventos de la clínica hacia sistemas externos, con patrón
|
||||||
|
**outbox + cron + reintentos + bitácora**. Sin webhooks activos suscritos no se
|
||||||
|
crea ninguna fila ni se envía nada (cero impacto).
|
||||||
|
|
||||||
|
## Modelos
|
||||||
|
|
||||||
|
### `skeen.webhook`
|
||||||
|
|
||||||
|
| Campo | Descripción |
|
||||||
|
| --- | --- |
|
||||||
|
| `name`, `url` | Nombre y URL destino |
|
||||||
|
| `eventos` | Claves separadas por coma; vacío = todos |
|
||||||
|
| `activo` | Habilitado |
|
||||||
|
| `secreto` | Con este secreto se firma cada entrega |
|
||||||
|
| `headers` | Cabeceras extra (JSON, opcional) |
|
||||||
|
| `notas`, `creado_por_login` | Metadatos |
|
||||||
|
| `ultima_entrega_at` | Última entrega correcta |
|
||||||
|
| `fallos_consecutivos` | Se desactiva tras N fallos (default 20, configurable en `skeen.webhooks.fallos_max`) |
|
||||||
|
|
||||||
|
### `skeen.webhook.evento` (outbox)
|
||||||
|
|
||||||
|
`webhook_id`, `evento`, `payload` (JSON), `estado`
|
||||||
|
(`pendiente|enviado|fallido|descartado`), `intentos`, `proximo_intento`,
|
||||||
|
`respuesta_codigo`, `respuesta_texto`, `enviado_at`, `clave_unica`
|
||||||
|
(idempotencia por evento+objeto).
|
||||||
|
|
||||||
|
## Catálogo de eventos
|
||||||
|
|
||||||
|
| Evento | Cuándo se emite |
|
||||||
|
| --- | --- |
|
||||||
|
| `cita.creada` | Al crear una cita |
|
||||||
|
| `cita.confirmada` | `state` → `confirmed` |
|
||||||
|
| `cita.cancelada` | `state` → `cancelled` |
|
||||||
|
| `cita.no_show` | `state` → `no_show` |
|
||||||
|
| `cita.completada` | `state` → `done` |
|
||||||
|
| `venta.pagada` | `state` → `paid` |
|
||||||
|
| `venta.cancelada` | `state` → `cancelled` o `refunded` |
|
||||||
|
| `pago.registrado` | Al crear un `skeen.pago` |
|
||||||
|
| `paciente.creado` | Al crear un `res.partner` con `is_patient` |
|
||||||
|
| `membresia.renovada` | `action_renovar` |
|
||||||
|
| `formulario.llenado` | `estado` → `llenado` |
|
||||||
|
| `compra.recibida` | `estado` → `recibida`/`recibida_parcial` |
|
||||||
|
|
||||||
|
### Ejemplo de payload (`cita.creada`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"reference": "CITA-0123",
|
||||||
|
"state": "pending",
|
||||||
|
"date": "2026-10-20",
|
||||||
|
"patient_id": 55,
|
||||||
|
"patient": "María López",
|
||||||
|
"service_id": 7,
|
||||||
|
"servicio": "Limpieza facial",
|
||||||
|
"doctor": "Dra. Pérez"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entrega
|
||||||
|
|
||||||
|
Cron cada 5 minutos (`skeen.webhook.service._cron_tick`) que toma los pendientes
|
||||||
|
vencidos y hace `POST` JSON con:
|
||||||
|
|
||||||
|
| Cabecera | Valor |
|
||||||
|
| --- | --- |
|
||||||
|
| `X-Skeen-Event` | Clave del evento |
|
||||||
|
| `X-Skeen-Delivery` | UUID de la entrega |
|
||||||
|
| `X-Skeen-Signature` | `sha256=<HMAC-SHA256 del cuerpo con el secreto>` |
|
||||||
|
|
||||||
|
* Timeouts cortos (5 s conexión / 15 s lectura).
|
||||||
|
* **Reintentos con backoff**: 1, 5, 30 min, 2 h, 6 h (tope 5 intentos); al
|
||||||
|
agotarlos el evento queda `fallido`.
|
||||||
|
* Tras N fallos consecutivos (default 20) el webhook se desactiva.
|
||||||
|
|
||||||
|
### Verificar la firma (Python)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hashlib, hmac
|
||||||
|
firma = request.headers['X-Skeen-Signature'] # 'sha256=...'
|
||||||
|
esperada = 'sha256=' + hmac.new(
|
||||||
|
SECRETO.encode(), request.body, hashlib.sha256).hexdigest()
|
||||||
|
assert hmac.compare_digest(firma, esperada)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificar la firma (Node.js)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const esperada = 'sha256=' + crypto
|
||||||
|
.createHmac('sha256', SECRETO)
|
||||||
|
.update(req.rawBody)
|
||||||
|
.digest('hex');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Método | Ruta | Acceso |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET/POST | `/skeen/frontend/v1/webhooks` | permiso `webhooks.editar` (admin/gerencia) |
|
||||||
|
| PUT/DELETE | `/skeen/frontend/v1/webhooks/<id>` | idem |
|
||||||
|
| GET | `/skeen/frontend/v1/webhooks/<id>/eventos` | idem (filtra por `estado`, `evento`, `limit`) |
|
||||||
|
| POST | `/skeen/frontend/v1/webhooks/<id>/probar` | idem (evento de prueba) |
|
||||||
|
| POST | `/skeen/frontend/v1/webhook_eventos/<id>/reintentar` | idem |
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Configuración → pestaña **Webhooks** (admin/gerencia): CRUD, selector de
|
||||||
|
eventos, secreto (generado o propio), visor del payload de ejemplo, log de
|
||||||
|
entregas con estado/código/fecha, reintento manual y "probar".
|
||||||
230
frontend/src/components/ApiKeysPanel.tsx
Normal file
230
frontend/src/components/ApiKeysPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Copy, KeyRound, Plus, RefreshCw, ShieldOff, Pencil } from 'lucide-react';
|
||||||
|
import { Button, Card, Input, Modal, TextArea, toast } from './ui';
|
||||||
|
import { odooApi, type ApiKey } from '../services/odoo';
|
||||||
|
|
||||||
|
// Pestaña Configuración -> API (solo administración).
|
||||||
|
|
||||||
|
const ESTADO_BADGE: Record<string, string> = {
|
||||||
|
activa: 'bg-emerald-100 text-emerald-700',
|
||||||
|
vencida: 'bg-amber-100 text-amber-700',
|
||||||
|
revocada: 'bg-rose-100 text-rose-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENDPOINTS_DOC = [
|
||||||
|
'available_slots → citas.read',
|
||||||
|
'create_appointment → citas.write',
|
||||||
|
'cancel_appointment → citas.write',
|
||||||
|
'patient_balance → pacientes.read',
|
||||||
|
'wallet_redeem → monedero.write',
|
||||||
|
'services → servicios.read',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ApiKeysPanel: FC = () => {
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [scopes, setScopes] = useState<{ clave: string; label: string }[]>([]);
|
||||||
|
const [, setCargando] = useState(true);
|
||||||
|
const [nuevaClave, setNuevaClave] = useState<string | null>(null);
|
||||||
|
const [mostrarCrear, setMostrarCrear] = useState(false);
|
||||||
|
const [editando, setEditando] = useState<ApiKey | null>(null);
|
||||||
|
|
||||||
|
// Formulario de creación
|
||||||
|
const [nombre, setNombre] = useState('');
|
||||||
|
const [alcances, setAlcances] = useState<string[]>([]);
|
||||||
|
const [vence, setVence] = useState('');
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.listApiKeys();
|
||||||
|
if (res.status === 'success') { setKeys(res.api_keys); setScopes(res.scopes); }
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudieron cargar las claves de API');
|
||||||
|
} finally {
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
const crear = async () => {
|
||||||
|
if (!nombre.trim()) { toast.error('Ponle un nombre a la clave'); return; }
|
||||||
|
setGuardando(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.createApiKey({ name: nombre.trim(), scopes: alcances, vence });
|
||||||
|
setNuevaClave(res.clave);
|
||||||
|
setMostrarCrear(false);
|
||||||
|
setNombre(''); setAlcances([]); setVence('');
|
||||||
|
await cargar();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo crear la clave');
|
||||||
|
} finally {
|
||||||
|
setGuardando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revocar = async (k: ApiKey) => {
|
||||||
|
if (!window.confirm(`¿Revocar la clave "${k.name}"? Dejará de funcionar de inmediato.`)) return;
|
||||||
|
try {
|
||||||
|
await odooApi.revocarApiKey(k.id);
|
||||||
|
toast.success('Clave revocada');
|
||||||
|
await cargar();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo revocar');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardarEdicion = async () => {
|
||||||
|
if (!editando) return;
|
||||||
|
try {
|
||||||
|
await odooApi.updateApiKey(editando.id, {
|
||||||
|
name: editando.name, scopes: editando.scopes, activo: editando.activo, vence: editando.vence || '', notas: editando.notas,
|
||||||
|
});
|
||||||
|
toast.success('Clave actualizada');
|
||||||
|
setEditando(null);
|
||||||
|
await cargar();
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo actualizar');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copiar = async (texto: string) => {
|
||||||
|
try { await navigator.clipboard.writeText(texto); toast.success('Copiado'); }
|
||||||
|
catch { toast.error('No se pudo copiar'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAlcance = (c: string) => setAlcances((prev) => prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="font-heading text-xl text-theme-heading flex items-center"><KeyRound size={19} className="mr-2" /> Claves de API</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={cargar}><RefreshCw size={15} className="mr-1" /> Actualizar</Button>
|
||||||
|
<Button size="sm" onClick={() => setMostrarCrear(true)}><Plus size={15} className="mr-1" /> Nueva clave</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-theme-muted">
|
||||||
|
Las claves de la API de terceros (<code>/skeen/api/v1/*</code>) se envían en
|
||||||
|
<code> Authorization: Bearer <clave></code> o <code>X-API-Key</code>. La clave legacy
|
||||||
|
de Hermes sigue funcionando.
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Nombre</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Prefijo</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Alcances</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Último uso</th>
|
||||||
|
<th className="text-right p-2 text-xs text-theme-muted uppercase">Usos</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Estado</th>
|
||||||
|
<th className="text-right p-2 text-xs text-theme-muted uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{keys.map((k) => (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td className="p-2 font-medium text-theme-heading">{k.name}</td>
|
||||||
|
<td className="p-2 font-mono text-xs text-theme-muted">{k.prefijo}…</td>
|
||||||
|
<td className="p-2 text-theme-muted">{k.scopes_label}</td>
|
||||||
|
<td className="p-2 text-theme-muted">{k.ultimo_uso || '—'}</td>
|
||||||
|
<td className="p-2 text-right text-theme-muted">{k.usos}</td>
|
||||||
|
<td className="p-2"><span className={`px-2 py-0.5 rounded-full text-xs ${ESTADO_BADGE[k.estado] || 'bg-theme-bg'}`}>{k.estado}</span></td>
|
||||||
|
<td className="p-2 text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setEditando({ ...k })}><Pencil size={14} /></Button>
|
||||||
|
{k.estado !== 'revocada' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => revocar(k)}><ShieldOff size={14} /></Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{keys.length === 0 && (
|
||||||
|
<tr><td colSpan={7} className="p-4 text-center text-theme-muted">No hay claves de API.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h4 className="font-heading text-lg text-theme-heading mb-2">Alcances por endpoint</h4>
|
||||||
|
<ul className="text-sm text-theme-muted list-disc pl-5 space-y-1">
|
||||||
|
{ENDPOINTS_DOC.map((e) => <li key={e}><code>{e}</code></li>)}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-xs text-theme-muted">Una clave sin alcances definidos tiene acceso a todos.</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal isOpen={mostrarCrear} onClose={() => setMostrarCrear(false)} title="Nueva clave de API"
|
||||||
|
footer={<><Button variant="outline" onClick={() => setMostrarCrear(false)}>Cancelar</Button><Button onClick={crear} disabled={guardando}>{guardando ? 'Creando…' : 'Crear'}</Button></>}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input label="Nombre" value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Integración Hermes" />
|
||||||
|
<Input label="Vence (opcional)" type="date" value={vence} onChange={(e) => setVence(e.target.value)} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-theme-muted mb-1.5">Alcances (vacío = todos)</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||||||
|
{scopes.map((s) => (
|
||||||
|
<label key={s.clave} className="flex items-center gap-2 text-sm text-theme-muted">
|
||||||
|
<input type="checkbox" checked={alcances.includes(s.clave)} onChange={() => toggleAlcance(s.clave)} className="h-4 w-4 rounded" />
|
||||||
|
{s.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={!!nuevaClave} onClose={() => setNuevaClave(null)} title="Guarda tu clave ahora"
|
||||||
|
footer={<Button onClick={() => setNuevaClave(null)}>Listo</Button>}>
|
||||||
|
<p className="text-sm text-theme-muted mb-2">Esta clave se muestra una sola vez. Cópiala y guárdala en un lugar seguro.</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 break-all rounded-lg bg-theme-bg px-3 py-2 text-sm">{nuevaClave}</code>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => copiar(nuevaClave || '')}><Copy size={15} /></Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={!!editando} onClose={() => setEditando(null)} title="Editar clave"
|
||||||
|
footer={<><Button variant="outline" onClick={() => setEditando(null)}>Cancelar</Button><Button onClick={guardarEdicion}>Guardar</Button></>}>
|
||||||
|
{editando && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input label="Nombre" value={editando.name} onChange={(e) => setEditando({ ...editando, name: e.target.value })} />
|
||||||
|
<Input label="Vence (opcional)" type="date" value={editando.vence} onChange={(e) => setEditando({ ...editando, vence: e.target.value })} />
|
||||||
|
<label className="flex items-center gap-2 text-sm text-theme-muted">
|
||||||
|
<input type="checkbox" checked={editando.activo} onChange={(e) => setEditando({ ...editando, activo: e.target.checked })} className="h-4 w-4 rounded" />
|
||||||
|
Activa
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-theme-muted mb-1.5">Alcances</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||||||
|
{scopes.map((s) => (
|
||||||
|
<label key={s.clave} className="flex items-center gap-2 text-sm text-theme-muted">
|
||||||
|
<input type="checkbox" checked={editando.scopes.includes(s.clave)}
|
||||||
|
onChange={() => setEditando({ ...editando, scopes: editando.scopes.includes(s.clave) ? editando.scopes.filter((x) => x !== s.clave) : [...editando.scopes, s.clave] })}
|
||||||
|
className="h-4 w-4 rounded" />
|
||||||
|
{s.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TextArea label="Notas" rows={2} value={editando.notas} onChange={(e) => setEditando({ ...editando, notas: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeysPanel;
|
||||||
@@ -49,7 +49,7 @@ import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
|
|||||||
import { useClinicSettings } from '../lib/clinic-settings';
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
import { useChatUnread } from '../lib/chat';
|
import { useChatUnread } from '../lib/chat';
|
||||||
import { useQuickLinks } from '../lib/quicklinks';
|
import { useQuickLinks } from '../lib/quicklinks';
|
||||||
import { odooApi } from '../services/odoo';
|
import { odooApi, licenciaModuloIncluido } from '../services/odoo';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -122,7 +122,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
||||||
const topNavItems = useMemo(
|
const topNavItems = useMemo(
|
||||||
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
|
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
|
||||||
[term]
|
[term]
|
||||||
@@ -144,8 +144,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
|
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
|
||||||
: s));
|
: s));
|
||||||
}
|
}
|
||||||
|
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
|
||||||
|
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
|
||||||
|
base = base
|
||||||
|
.filter((s) => s.label !== 'WACRM')
|
||||||
|
.map((s) => ({ ...s, items: s.items.filter((i) => i.href !== '/chat') }))
|
||||||
|
.filter((s) => s.items.length > 0);
|
||||||
|
}
|
||||||
return traducirMenu(base, term);
|
return traducirMenu(base, term);
|
||||||
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
|
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
|
|||||||
232
frontend/src/components/LicenciaPanel.tsx
Normal file
232
frontend/src/components/LicenciaPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle, BadgeCheck, KeyRound, RefreshCw, ShieldOff, Trash2 } from 'lucide-react';
|
||||||
|
import { Button, Card, Select, TextArea, toast } from './ui';
|
||||||
|
import { odooApi, type LicenciaEstadoResponse } from '../services/odoo';
|
||||||
|
|
||||||
|
// Pestaña Configuración -> Licencia (solo administración).
|
||||||
|
|
||||||
|
const ESTADO_COLOR: Record<string, string> = {
|
||||||
|
ok: 'bg-emerald-500',
|
||||||
|
cerca: 'bg-amber-500',
|
||||||
|
excedido: 'bg-rose-500',
|
||||||
|
sin_limite: 'bg-theme-border-strong',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LicenciaPanel: FC = () => {
|
||||||
|
const [estado, setEstado] = useState<LicenciaEstadoResponse | null>(null);
|
||||||
|
const [cargando, setCargando] = useState(true);
|
||||||
|
const [clave, setClave] = useState('');
|
||||||
|
const [validando, setValidando] = useState(false);
|
||||||
|
const [aplicando, setAplicando] = useState(false);
|
||||||
|
const [resultadoValidacion, setResultadoValidacion] = useState<{ valida: boolean; error: string | null } | null>(null);
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getLicencia();
|
||||||
|
if (res.status === 'success') setEstado(res);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo cargar el estado de la licencia');
|
||||||
|
} finally {
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
const validar = async () => {
|
||||||
|
if (!clave.trim()) { toast.error('Pega una clave de licencia'); return; }
|
||||||
|
setValidando(true);
|
||||||
|
setResultadoValidacion(null);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.validarLicencia(clave.trim());
|
||||||
|
setResultadoValidacion({ valida: res.valida, error: res.error });
|
||||||
|
if (res.valida) toast.success('La clave es válida');
|
||||||
|
else toast.error(res.error || 'Clave inválida');
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo validar la clave');
|
||||||
|
} finally {
|
||||||
|
setValidando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const aplicar = async () => {
|
||||||
|
if (!clave.trim()) { toast.error('Pega una clave de licencia'); return; }
|
||||||
|
setAplicando(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.putLicencia({ clave: clave.trim(), modo: 'aviso' });
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setEstado(res);
|
||||||
|
setClave('');
|
||||||
|
setResultadoValidacion(null);
|
||||||
|
toast.success('Licencia aplicada (modo aviso)');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo aplicar la licencia');
|
||||||
|
} finally {
|
||||||
|
setAplicando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const quitar = async () => {
|
||||||
|
if (!window.confirm('¿Quitar la licencia? Todas las funciones vuelven a quedar habilitadas.')) return;
|
||||||
|
try {
|
||||||
|
const res = await odooApi.putLicencia({ clave: '' });
|
||||||
|
if (res.status === 'success') { setEstado(res); toast.success('Licencia retirada'); }
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo quitar la licencia');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cambiarModo = async (modo: 'aviso' | 'bloqueo') => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.putLicencia({ modo });
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setEstado(res);
|
||||||
|
toast.success(modo === 'bloqueo' ? 'Modo bloqueo activado' : 'Modo aviso activado');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo cambiar el modo');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cargando) return <p className="text-sm text-theme-muted">Cargando licencia…</p>;
|
||||||
|
if (!estado) return <p className="text-sm text-theme-muted">No se pudo cargar la licencia.</p>;
|
||||||
|
|
||||||
|
const lic = estado.licencia;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{lic.aplicada ? <BadgeCheck size={20} className="text-emerald-600" /> : <ShieldOff size={20} className="text-theme-muted" />}
|
||||||
|
<h3 className="font-heading text-xl text-theme-heading">Licencia</h3>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={cargar}><RefreshCw size={15} className="mr-1" /> Actualizar</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!lic.aplicada ? (
|
||||||
|
<div className="mt-3 rounded-xl bg-theme-accent-bg px-4 py-3 text-sm text-theme-heading">
|
||||||
|
Sin licencia aplicada — todas las funciones habilitadas.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
|
||||||
|
<div><p className="text-xs text-theme-muted">Plan</p><p className="font-medium text-theme-heading">{lic.plan_label || lic.plan || '—'}</p></div>
|
||||||
|
<div><p className="text-xs text-theme-muted">Cliente</p><p className="font-medium text-theme-heading">{lic.cliente || '—'}</p></div>
|
||||||
|
<div><p className="text-xs text-theme-muted">Vence</p><p className="font-medium text-theme-heading">{lic.vence || 'sin vencimiento'}{lic.dias_restantes !== null && ` (${lic.dias_restantes} días)`}</p></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Modo</p>
|
||||||
|
<Select
|
||||||
|
value={lic.modo}
|
||||||
|
options={[{ value: 'aviso', label: 'Aviso' }, { value: 'bloqueo', label: 'Bloqueo' }]}
|
||||||
|
onChange={(e) => cambiarModo(e.target.value as 'aviso' | 'bloqueo')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lic.aviso_vence && (
|
||||||
|
<p className="mt-3 flex items-center gap-2 text-sm text-amber-600">
|
||||||
|
<AlertTriangle size={16} /> La licencia vence en {lic.dias_restantes} días.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lic.aplicada && lic.modo === 'aviso' && (
|
||||||
|
<p className="mt-2 text-xs text-theme-muted">
|
||||||
|
Modo aviso: los módulos fuera del plan y los límites solo se advierten; no se bloquea nada.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h4 className="font-heading text-lg text-theme-heading mb-2 flex items-center"><KeyRound size={17} className="mr-2" /> Clave de licencia</h4>
|
||||||
|
<TextArea
|
||||||
|
label="Pega aquí la clave que te entregó tu proveedor"
|
||||||
|
rows={3}
|
||||||
|
value={clave}
|
||||||
|
onChange={(e) => setClave(e.target.value)}
|
||||||
|
placeholder="SKN1...."
|
||||||
|
/>
|
||||||
|
{resultadoValidacion && (
|
||||||
|
<p className={`mt-2 text-sm ${resultadoValidacion.valida ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||||
|
{resultadoValidacion.valida ? 'Clave válida.' : `Clave rechazada: ${resultadoValidacion.error}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={validar} disabled={validando}>{validando ? 'Validando…' : 'Validar'}</Button>
|
||||||
|
<Button onClick={aplicar} disabled={aplicando}><BadgeCheck size={15} className="mr-1" />{aplicando ? 'Aplicando…' : 'Aplicar licencia'}</Button>
|
||||||
|
{lic.aplicada && (
|
||||||
|
<Button variant="ghost" onClick={quitar}><Trash2 size={15} className="mr-1" /> Quitar licencia</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h4 className="font-heading text-lg text-theme-heading mb-3">Uso vs límites</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{estado.limites.map((f) => (
|
||||||
|
<div key={f.clave}>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-theme-muted">{f.label}</span>
|
||||||
|
<span className="text-theme-heading">
|
||||||
|
{f.uso}{f.limite > 0 ? ` / ${f.limite}` : ' (sin límite)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-2 rounded-full bg-theme-bg overflow-hidden">
|
||||||
|
<div className={`h-full ${ESTADO_COLOR[f.estado] || 'bg-theme-accent'}`}
|
||||||
|
style={{ width: f.limite > 0 ? `${Math.min(100, f.porcentaje)}%` : '0%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h4 className="font-heading text-lg text-theme-heading mb-3">Módulos del plan</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{estado.modulos.map((m) => (
|
||||||
|
<div key={m.clave} className={`flex items-center justify-between rounded-lg px-3 py-2 text-sm ${m.incluido ? 'bg-emerald-50 text-emerald-800' : 'bg-rose-50 text-rose-700'}`}>
|
||||||
|
<span>{m.label}</span>
|
||||||
|
<span className="text-xs">{m.base ? 'base' : (m.incluido ? 'incluido' : 'no incluido')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h4 className="font-heading text-lg text-theme-heading mb-3">Planes disponibles</h4>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Plan</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Módulos</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{estado.planes.map((p) => (
|
||||||
|
<tr key={p.clave}>
|
||||||
|
<td className="p-2 font-medium text-theme-heading">{p.label}</td>
|
||||||
|
<td className="p-2 text-theme-muted">{p.modulos.length ? p.modulos.join(', ') : 'todos'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LicenciaPanel;
|
||||||
@@ -51,7 +51,7 @@ import { useGlosario, traducirMenu } from '../lib/glosario';
|
|||||||
import { useClinicSettings } from '../lib/clinic-settings';
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
import { useChatUnread } from '../lib/chat';
|
import { useChatUnread } from '../lib/chat';
|
||||||
import { useQuickLinks } from '../lib/quicklinks';
|
import { useQuickLinks } from '../lib/quicklinks';
|
||||||
import { odooApi } from '../services/odoo';
|
import { odooApi, licenciaModuloIncluido } from '../services/odoo';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -135,7 +135,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
|
||||||
const menuGroupsT = useMemo(() => {
|
const menuGroupsT = useMemo(() => {
|
||||||
let base = menuGroups;
|
let base = menuGroups;
|
||||||
if (membresiasCfg.activo) {
|
if (membresiasCfg.activo) {
|
||||||
@@ -153,8 +153,15 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
? { ...g, items: [...g.items, COMISIONES_ITEM] }
|
? { ...g, items: [...g.items, COMISIONES_ITEM] }
|
||||||
: g));
|
: g));
|
||||||
}
|
}
|
||||||
|
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
|
||||||
|
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
|
||||||
|
base = base
|
||||||
|
.filter((g) => g.label !== 'WACRM')
|
||||||
|
.map((g) => ({ ...g, items: g.items.filter((i) => i.href !== '/chat') }))
|
||||||
|
.filter((g) => g.items.length > 0);
|
||||||
|
}
|
||||||
return traducirMenu(base, term);
|
return traducirMenu(base, term);
|
||||||
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
|
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
||||||
|
|||||||
260
frontend/src/components/WebhooksPanel.tsx
Normal file
260
frontend/src/components/WebhooksPanel.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Plus, RefreshCw, Send, Trash2, Webhook as WebhookIcon, Pencil, RotateCw } from 'lucide-react';
|
||||||
|
import { Button, Card, Input, Modal, Select, TextArea, toast } from './ui';
|
||||||
|
import { odooApi, type Webhook, type WebhookEvento, type WebhookEventoCatalogo } from '../services/odoo';
|
||||||
|
|
||||||
|
// Pestaña Configuración -> Webhooks.
|
||||||
|
|
||||||
|
const ESTADO_BADGE: Record<string, string> = {
|
||||||
|
enviado: 'bg-emerald-100 text-emerald-700',
|
||||||
|
fallido: 'bg-rose-100 text-rose-700',
|
||||||
|
pendiente: 'bg-amber-100 text-amber-700',
|
||||||
|
descartado: 'bg-theme-bg text-theme-muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string; url: string; eventos: string[]; activo: boolean; secreto: string; notas: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORM_VACIO: FormState = { name: '', url: '', eventos: [], activo: true, secreto: '', notas: '' };
|
||||||
|
|
||||||
|
const WebhooksPanel: FC = () => {
|
||||||
|
const [hooks, setHooks] = useState<Webhook[]>([]);
|
||||||
|
const [catalogo, setCatalogo] = useState<WebhookEventoCatalogo[]>([]);
|
||||||
|
const [, setCargando] = useState(true);
|
||||||
|
const [form, setForm] = useState<FormState>(FORM_VACIO);
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [mostrarForm, setMostrarForm] = useState(false);
|
||||||
|
const [eventoEjemplo, setEventoEjemplo] = useState<string>('cita.creada');
|
||||||
|
const [logDe, setLogDe] = useState<Webhook | null>(null);
|
||||||
|
const [log, setLog] = useState<WebhookEvento[]>([]);
|
||||||
|
const [filtroEstado, setFiltroEstado] = useState('');
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.listWebhooks();
|
||||||
|
if (res.status === 'success') { setHooks(res.webhooks); setCatalogo(res.eventos); }
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudieron cargar los webhooks');
|
||||||
|
} finally {
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
const abrirNuevo = () => { setForm(FORM_VACIO); setEditId(null); setMostrarForm(true); };
|
||||||
|
const abrirEditar = (w: Webhook) => {
|
||||||
|
setForm({ name: w.name, url: w.url, eventos: w.eventos, activo: w.activo, secreto: '', notas: w.notas });
|
||||||
|
setEditId(w.id); setMostrarForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardar = async () => {
|
||||||
|
if (!form.name.trim() || !form.url.trim()) { toast.error('Nombre y URL son obligatorios'); return; }
|
||||||
|
setGuardando(true);
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await odooApi.updateWebhook(editId, { name: form.name, url: form.url, eventos: form.eventos, activo: form.activo, notas: form.notas });
|
||||||
|
toast.success('Webhook actualizado');
|
||||||
|
} else {
|
||||||
|
const res = await odooApi.createWebhook({ name: form.name, url: form.url, eventos: form.eventos, activo: form.activo, secreto: form.secreto, notas: form.notas });
|
||||||
|
toast.success(`Webhook creado. Secreto: ${res.secreto}`);
|
||||||
|
}
|
||||||
|
setMostrarForm(false);
|
||||||
|
await cargar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo guardar el webhook');
|
||||||
|
} finally {
|
||||||
|
setGuardando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eliminar = async (w: Webhook) => {
|
||||||
|
if (!window.confirm(`¿Eliminar el webhook "${w.name}"?`)) return;
|
||||||
|
try { await odooApi.deleteWebhook(w.id); toast.success('Eliminado'); await cargar(); }
|
||||||
|
catch { toast.error('No se pudo eliminar'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const probar = async (w: Webhook) => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.probarWebhook(w.id);
|
||||||
|
if (res.ok) toast.success(res.message);
|
||||||
|
else toast.error(res.message);
|
||||||
|
await cargar();
|
||||||
|
} catch { toast.error('No se pudo probar el webhook'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const verLog = async (w: Webhook, estado = filtroEstado) => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.listWebhookEventos(w.id, { estado: estado || undefined, limit: 100 });
|
||||||
|
setLog(res.eventos); setLogDe(w);
|
||||||
|
} catch { toast.error('No se pudo cargar el log'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const reintentar = async (e: WebhookEvento) => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.reintentarWebhookEvento(e.id);
|
||||||
|
toast.success(`Reintento: ${res.estado}`);
|
||||||
|
if (logDe) await verLog(logDe);
|
||||||
|
await cargar();
|
||||||
|
} catch { toast.error('No se pudo reintentar'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEvento = (c: string) => setForm((f) => ({ ...f, eventos: f.eventos.includes(c) ? f.eventos.filter((x) => x !== c) : [...f.eventos, c] }));
|
||||||
|
|
||||||
|
const ejemplo = catalogo.find((c) => c.clave === eventoEjemplo)?.ejemplo || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="font-heading text-xl text-theme-heading flex items-center"><WebhookIcon size={19} className="mr-2" /> Webhooks salientes</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={cargar}><RefreshCw size={15} className="mr-1" /> Actualizar</Button>
|
||||||
|
<Button size="sm" onClick={abrirNuevo}><Plus size={15} className="mr-1" /> Nuevo webhook</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-theme-muted">
|
||||||
|
Cada entrega lleva <code>X-Skeen-Event</code>, <code>X-Skeen-Delivery</code> y
|
||||||
|
<code> X-Skeen-Signature: sha256=<HMAC-SHA256 del cuerpo con el secreto></code>.
|
||||||
|
La entrega es asíncrona (cron cada 5 min) con reintentos y backoff.
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Nombre</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">URL</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Eventos</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Última entrega</th>
|
||||||
|
<th className="text-left p-2 text-xs text-theme-muted uppercase">Estado</th>
|
||||||
|
<th className="text-right p-2 text-xs text-theme-muted uppercase">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{hooks.map((w) => (
|
||||||
|
<tr key={w.id}>
|
||||||
|
<td className="p-2 font-medium text-theme-heading">{w.name}</td>
|
||||||
|
<td className="p-2 text-theme-muted max-w-[240px] truncate">{w.url}</td>
|
||||||
|
<td className="p-2 text-theme-muted max-w-[220px] truncate">{w.eventos_label}</td>
|
||||||
|
<td className="p-2 text-theme-muted">{w.ultima_entrega_at || '—'}</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs ${w.activo ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'}`}>
|
||||||
|
{w.activo ? 'activo' : 'inactivo'}{w.fallos_consecutivos ? ` (${w.fallos_consecutivos} fallos)` : ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="sm" title="Probar" onClick={() => probar(w)}><Send size={14} /></Button>
|
||||||
|
<Button variant="ghost" size="sm" title="Log de entregas" onClick={() => verLog(w, '')}><RefreshCw size={14} /></Button>
|
||||||
|
<Button variant="ghost" size="sm" title="Editar" onClick={() => abrirEditar(w)}><Pencil size={14} /></Button>
|
||||||
|
<Button variant="ghost" size="sm" title="Eliminar" onClick={() => eliminar(w)}><Trash2 size={14} /></Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{hooks.length === 0 && (
|
||||||
|
<tr><td colSpan={6} className="p-4 text-center text-theme-muted">No hay webhooks configurados. Sin webhooks activos no se emite ningún evento.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<h4 className="font-heading text-lg text-theme-heading mb-2">Catálogo de eventos</h4>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{catalogo.map((c) => (
|
||||||
|
<button key={c.clave} type="button" onClick={() => setEventoEjemplo(c.clave)}
|
||||||
|
className={`block w-full text-left rounded-lg px-3 py-1.5 text-sm ${eventoEjemplo === c.clave ? 'bg-theme-accent-bg text-theme-heading' : 'text-theme-muted hover:bg-theme-bg'}`}>
|
||||||
|
<code>{c.clave}</code> — {c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted mb-1">Payload de ejemplo</p>
|
||||||
|
<pre className="rounded-xl bg-theme-bg p-3 text-xs overflow-auto max-h-72">{JSON.stringify(ejemplo, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal isOpen={mostrarForm} onClose={() => setMostrarForm(false)} title={editId ? 'Editar webhook' : 'Nuevo webhook'} maxWidth="2xl"
|
||||||
|
footer={<><Button variant="outline" onClick={() => setMostrarForm(false)}>Cancelar</Button><Button onClick={guardar} disabled={guardando}>{guardando ? 'Guardando…' : 'Guardar'}</Button></>}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input label="Nombre" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
<Input label="URL" value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://cliente.example/hook" />
|
||||||
|
<label className="flex items-center gap-2 text-sm text-theme-muted">
|
||||||
|
<input type="checkbox" checked={form.activo} onChange={(e) => setForm({ ...form, activo: e.target.checked })} className="h-4 w-4 rounded" /> Activo
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-theme-muted mb-1.5">Eventos (vacío = todos)</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 max-h-52 overflow-auto">
|
||||||
|
{catalogo.map((c) => (
|
||||||
|
<label key={c.clave} className="flex items-center gap-2 text-sm text-theme-muted">
|
||||||
|
<input type="checkbox" checked={form.eventos.includes(c.clave)} onChange={() => toggleEvento(c.clave)} className="h-4 w-4 rounded" />
|
||||||
|
{c.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!editId && (
|
||||||
|
<Input label="Secreto (vacío = se genera uno)" value={form.secreto} onChange={(e) => setForm({ ...form, secreto: e.target.value })} />
|
||||||
|
)}
|
||||||
|
<TextArea label="Notas" rows={2} value={form.notas} onChange={(e) => setForm({ ...form, notas: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={!!logDe} onClose={() => setLogDe(null)} title={`Entregas — ${logDe?.name || ''}`} maxWidth="2xl">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Select value={filtroEstado} options={[{ value: '', label: 'Todos' }, { value: 'pendiente', label: 'Pendiente' }, { value: 'enviado', label: 'Enviado' }, { value: 'fallido', label: 'Fallido' }, { value: 'descartado', label: 'Descartado' }]}
|
||||||
|
onChange={(e) => { setFiltroEstado(e.target.value); if (logDe) verLog(logDe, e.target.value); }} />
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => logDe && verLog(logDe)}><RefreshCw size={14} /></Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-96">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-theme-bg">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2">Evento</th>
|
||||||
|
<th className="text-left p-2">Estado</th>
|
||||||
|
<th className="text-left p-2">Código</th>
|
||||||
|
<th className="text-left p-2">Fecha</th>
|
||||||
|
<th className="text-right p-2">Acción</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{log.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className="p-2">{e.evento_label}<div className="text-theme-muted">{e.respuesta_texto?.slice(0, 60)}</div></td>
|
||||||
|
<td className="p-2"><span className={`px-2 py-0.5 rounded-full ${ESTADO_BADGE[e.estado] || 'bg-theme-bg'}`}>{e.estado_label}</span>{e.intentos > 1 && <span className="text-theme-muted"> ×{e.intentos}</span>}</td>
|
||||||
|
<td className="p-2">{e.respuesta_codigo || '—'}</td>
|
||||||
|
<td className="p-2">{e.enviado_at || e.creado_at}</td>
|
||||||
|
<td className="p-2 text-right">
|
||||||
|
{(e.estado === 'fallido' || e.estado === 'pendiente') && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => reintentar(e)}><RotateCw size={13} /></Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{log.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-theme-muted">Sin entregas.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebhooksPanel;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT } from '../services/odoo';
|
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT } from '../services/odoo';
|
||||||
|
|
||||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
||||||
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||||
@@ -14,6 +14,11 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
|||||||
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
||||||
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
||||||
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
||||||
|
licencia: {
|
||||||
|
...LICENCIA_DEFAULT,
|
||||||
|
...(settings?.licencia || {}),
|
||||||
|
limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let actual: ClinicSettings = normalizar(null);
|
let actual: ClinicSettings = normalizar(null);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const PERM_KEYS: string[] = [
|
|||||||
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
||||||
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
|
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
|
||||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||||
|
'api.claves', 'webhooks.editar',
|
||||||
];
|
];
|
||||||
|
|
||||||
const W = ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
|
const W = ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
|
||||||
@@ -84,6 +85,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
|||||||
'proveedores.ver': ALL, 'proveedores.editar': AG,
|
'proveedores.ver': ALL, 'proveedores.editar': AG,
|
||||||
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
|
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
|
||||||
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||||
|
'api.claves': AD, 'webhooks.editar': AG,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BASE_ROLES = ['admin', 'medico', 'recepcion', 'enfermeria',
|
export const BASE_ROLES = ['admin', 'medico', 'recepcion', 'enfermeria',
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import FormulariosPanel from '../components/FormulariosPanel';
|
|||||||
import MembresiasPanel from '../components/MembresiasPanel';
|
import MembresiasPanel from '../components/MembresiasPanel';
|
||||||
import ComprasPanel from '../components/ComprasPanel';
|
import ComprasPanel from '../components/ComprasPanel';
|
||||||
import ComisionesPanel from '../components/ComisionesPanel';
|
import ComisionesPanel from '../components/ComisionesPanel';
|
||||||
|
import LicenciaPanel from '../components/LicenciaPanel';
|
||||||
|
import ApiKeysPanel from '../components/ApiKeysPanel';
|
||||||
|
import WebhooksPanel from '../components/WebhooksPanel';
|
||||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||||
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
@@ -44,11 +47,14 @@ const TABS = [
|
|||||||
{ key: 'recetas', label: 'Recetas' },
|
{ key: 'recetas', label: 'Recetas' },
|
||||||
{ key: 'catalogos', label: 'Catálogos' },
|
{ key: 'catalogos', label: 'Catálogos' },
|
||||||
{ key: 'tickets', label: 'Tickets' },
|
{ key: 'tickets', label: 'Tickets' },
|
||||||
|
{ key: 'licencia', label: 'Licencia' },
|
||||||
|
{ key: 'api', label: 'API' },
|
||||||
|
{ key: 'webhooks', label: 'Webhooks' },
|
||||||
{ key: 'auditoria', label: 'Auditoría' },
|
{ key: 'auditoria', label: 'Auditoría' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
|
||||||
|
|
||||||
const SUBCATALOGOS = [
|
const SUBCATALOGOS = [
|
||||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||||
@@ -76,6 +82,9 @@ const Configuracion: FC = () => {
|
|||||||
compras: 'compras.editar',
|
compras: 'compras.editar',
|
||||||
comisiones: 'comisiones.editar',
|
comisiones: 'comisiones.editar',
|
||||||
recursos: 'recursos.editar',
|
recursos: 'recursos.editar',
|
||||||
|
licencia: 'config.editar',
|
||||||
|
api: 'api.claves',
|
||||||
|
webhooks: 'webhooks.editar',
|
||||||
};
|
};
|
||||||
const tabsVisibles = useMemo(
|
const tabsVisibles = useMemo(
|
||||||
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
|
||||||
@@ -445,6 +454,12 @@ const Configuracion: FC = () => {
|
|||||||
<FormulariosPanel />
|
<FormulariosPanel />
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : tab === 'licencia' ? (
|
||||||
|
<LicenciaPanel />
|
||||||
|
) : tab === 'api' ? (
|
||||||
|
<ApiKeysPanel />
|
||||||
|
) : tab === 'webhooks' ? (
|
||||||
|
<WebhooksPanel />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1076,6 +1076,138 @@ export interface ClinicSettings {
|
|||||||
recursos: RecursosSettings;
|
recursos: RecursosSettings;
|
||||||
compras: ComprasSettings;
|
compras: ComprasSettings;
|
||||||
comisiones: ComisionesSettings;
|
comisiones: ComisionesSettings;
|
||||||
|
licencia: LicenciaSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
|
||||||
|
export interface LicenciaLimites {
|
||||||
|
usuarios_max: number;
|
||||||
|
sucursales_max: number;
|
||||||
|
citas_mes_max: number;
|
||||||
|
almacenamiento_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenciaSettings {
|
||||||
|
plan: '' | 'basico' | 'profesional' | 'enterprise' | 'custom';
|
||||||
|
modo: 'aviso' | 'bloqueo';
|
||||||
|
cliente: string;
|
||||||
|
vence: string;
|
||||||
|
clave: string;
|
||||||
|
modulos: string[];
|
||||||
|
limites: LicenciaLimites;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LICENCIA_LIMITES_DEFAULT: LicenciaLimites = {
|
||||||
|
usuarios_max: 0, sucursales_max: 0, citas_mes_max: 0, almacenamiento_mb: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LICENCIA_DEFAULT: LicenciaSettings = {
|
||||||
|
plan: '', modo: 'aviso', cliente: '', vence: '', clave: '', modulos: [],
|
||||||
|
limites: { ...LICENCIA_LIMITES_DEFAULT },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True si la licencia está aplicada y en modo bloqueo. */
|
||||||
|
export const licenciaBloqueo = (s?: Partial<LicenciaSettings> | null): boolean =>
|
||||||
|
Boolean(s && s.clave && s.modo === 'bloqueo');
|
||||||
|
|
||||||
|
/** True si el módulo gateable está incluido (sin licencia/bloqueo = sí). */
|
||||||
|
export const licenciaModuloIncluido = (s: Partial<LicenciaSettings> | null | undefined, modulo: string): boolean => {
|
||||||
|
if (!licenciaBloqueo(s)) return true;
|
||||||
|
const modulos = s?.modulos || [];
|
||||||
|
if (modulos.length === 0) return true;
|
||||||
|
return modulos.includes(modulo);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LicenciaEstado {
|
||||||
|
plan: string;
|
||||||
|
plan_label: string;
|
||||||
|
modo: 'aviso' | 'bloqueo';
|
||||||
|
cliente: string;
|
||||||
|
vence: string;
|
||||||
|
dias_restantes: number | null;
|
||||||
|
aplicada: boolean;
|
||||||
|
bloqueo_activo: boolean;
|
||||||
|
aviso_vence: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenciaLimiteFila {
|
||||||
|
clave: string;
|
||||||
|
label: string;
|
||||||
|
uso: number;
|
||||||
|
limite: number;
|
||||||
|
porcentaje: number;
|
||||||
|
estado: 'ok' | 'cerca' | 'excedido' | 'sin_limite';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenciaModuloFila {
|
||||||
|
clave: string;
|
||||||
|
label: string;
|
||||||
|
base: boolean;
|
||||||
|
incluido: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenciaEstadoResponse {
|
||||||
|
status: string;
|
||||||
|
licencia: LicenciaEstado;
|
||||||
|
limites: LicenciaLimiteFila[];
|
||||||
|
modulos: LicenciaModuloFila[];
|
||||||
|
planes: { clave: string; label: string; modulos: string[] }[];
|
||||||
|
aviso: string | null;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
prefijo: string;
|
||||||
|
activo: boolean;
|
||||||
|
scopes: string[];
|
||||||
|
scopes_label: string;
|
||||||
|
vence: string;
|
||||||
|
ultimo_uso: string;
|
||||||
|
usos: number;
|
||||||
|
notas: string;
|
||||||
|
creado_por_login: string;
|
||||||
|
revocada_at: string;
|
||||||
|
revocada_por: string;
|
||||||
|
estado: 'activa' | 'vencida' | 'revocada';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Webhook {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
eventos: string[];
|
||||||
|
eventos_label: string;
|
||||||
|
activo: boolean;
|
||||||
|
tiene_secreto: boolean;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
notas: string;
|
||||||
|
creado_por_login: string;
|
||||||
|
ultima_entrega_at: string;
|
||||||
|
fallos_consecutivos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookEvento {
|
||||||
|
id: number;
|
||||||
|
webhook_id: number;
|
||||||
|
webhook: string;
|
||||||
|
evento: string;
|
||||||
|
evento_label: string;
|
||||||
|
estado: 'pendiente' | 'enviado' | 'fallido' | 'descartado';
|
||||||
|
estado_label: string;
|
||||||
|
intentos: number;
|
||||||
|
proximo_intento: string;
|
||||||
|
respuesta_codigo: number;
|
||||||
|
respuesta_texto: string;
|
||||||
|
enviado_at: string;
|
||||||
|
creado_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookEventoCatalogo {
|
||||||
|
clave: string;
|
||||||
|
label: string;
|
||||||
|
ejemplo: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Reservas en línea (portal público de auto-agendado) ----
|
// ---- Reservas en línea (portal público de auto-agendado) ----
|
||||||
@@ -2082,6 +2214,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
|||||||
recursos: RECURSOS_DEFAULT,
|
recursos: RECURSOS_DEFAULT,
|
||||||
compras: COMPRAS_DEFAULT,
|
compras: COMPRAS_DEFAULT,
|
||||||
comisiones: COMISIONES_DEFAULT,
|
comisiones: COMISIONES_DEFAULT,
|
||||||
|
licencia: LICENCIA_DEFAULT,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FrontendUser {
|
export interface FrontendUser {
|
||||||
@@ -2776,6 +2909,79 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Licencia por plan ----
|
||||||
|
async getLicencia(): Promise<LicenciaEstadoResponse> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/licencia`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async putLicencia(payload: { clave?: string; modo?: 'aviso' | 'bloqueo' }): Promise<LicenciaEstadoResponse> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/licencia`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async validarLicencia(clave: string): Promise<{ status: string; valida: boolean; error: string | null; payload: Record<string, unknown> | null; dias_restantes: number | null; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/licencia/validar`, { clave });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Claves de API de terceros ----
|
||||||
|
async listApiKeys(): Promise<{ status: string; api_keys: ApiKey[]; scopes: { clave: string; label: string }[] }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/api_keys`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createApiKey(payload: { name: string; scopes?: string[]; vence?: string; notas?: string }): Promise<{ status: string; clave: string; api_key: ApiKey; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/api_keys`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateApiKey(id: number, payload: { name?: string; scopes?: string[]; activo?: boolean; vence?: string; notas?: string }): Promise<{ status: string; api_key: ApiKey }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/api_keys/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async revocarApiKey(id: number): Promise<{ status: string; api_key: ApiKey; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/api_keys/${id}/revocar`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Webhooks salientes ----
|
||||||
|
async listWebhooks(): Promise<{ status: string; webhooks: Webhook[]; eventos: WebhookEventoCatalogo[]; estados: { clave: string; label: string }[]; formato_firma: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/webhooks`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createWebhook(payload: { name: string; url: string; eventos: string[]; activo?: boolean; secreto?: string; headers?: Record<string, string>; notas?: string }): Promise<{ status: string; webhook: Webhook; secreto: string; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhooks`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateWebhook(id: number, payload: Partial<{ name: string; url: string; eventos: string[]; activo: boolean; secreto: string; notas: string }>): Promise<{ status: string; webhook: Webhook }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteWebhook(id: number): Promise<{ status: string; message?: string }> {
|
||||||
|
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listWebhookEventos(id: number, params?: { estado?: string; evento?: string; limit?: number }): Promise<{ status: string; eventos: WebhookEvento[]; total: number; resumen: Record<string, number> }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}/eventos`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async probarWebhook(id: number): Promise<{ status: string; ok: boolean; message: string; evento: WebhookEvento | null }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}/probar`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async reintentarWebhookEvento(id: number): Promise<{ status: string; estado: string; evento: WebhookEvento }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhook_eventos/${id}/reintentar`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Compras y proveedores ----
|
// ---- Compras y proveedores ----
|
||||||
// Con `compras.activo=false` (default) el backend responde 403 y estas
|
// Con `compras.activo=false` (default) el backend responde 403 y estas
|
||||||
// llamadas nunca se hacen desde la UI (la sección no se monta).
|
// llamadas nunca se hacen desde la UI (la sección no se monta).
|
||||||
|
|||||||
219
infra/licencias/generar_licencia.py
Executable file
219
infra/licencias/generar_licencia.py
Executable file
@@ -0,0 +1,219 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Generador de claves de licencia SKEEN (herramienta del vendedor).
|
||||||
|
|
||||||
|
Vive FUERA del producto: no se despliega en las instancias de las clínicas.
|
||||||
|
Solo el vendedor corre este script para emitir/verificar una clave. La clave se
|
||||||
|
firma con HMAC-SHA256 usando la clave del vendedor; la instancia valida la firma
|
||||||
|
con la misma clave (symmetric HMAC), por lo que la clave del vendedor debe estar
|
||||||
|
configurada en cada instancia que aplique licencias:
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
Formato de la clave (texto, una sola línea):
|
||||||
|
|
||||||
|
SKN1.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor))>
|
||||||
|
|
||||||
|
`payload_json` = JSON con claves ordenadas y separadores mínimos:
|
||||||
|
{\"cliente\":...,\"limites\":{...},\"modulos\":[...],\"plan\":...,\"vence\":...}
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
generar_licencia.py generar --cliente "Spa Bella" --plan profesional \
|
||||||
|
--vence 2027-12-31 [--modulos whatsapp,recursos] \
|
||||||
|
[--usuarios-max N --sucursales-max N --citas-mes-max N --almacenamiento-mb N]
|
||||||
|
generar_licencia.py verificar --clave "SKN1...."
|
||||||
|
|
||||||
|
El plan define los módulos por default; --modulos los pisa. En los planes
|
||||||
|
`basico`/`profesional`/`enterprise` la lista de módulos es explícita; `custom`
|
||||||
|
usa exactamente la lista indicada (vacía = todos los módulos).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
PREFIJO = 'SKN1'
|
||||||
|
VENDOR_KEY_ENV = 'SKEEN_LICENCIA_VENDOR_KEY'
|
||||||
|
VENDOR_KEY_FILE = '/root/.skeen-licencias/vendor.key'
|
||||||
|
|
||||||
|
MODULOS_BASE = ['agenda', 'pacientes', 'pos', 'inventario', 'reportes']
|
||||||
|
MODULOS_GATEABLES = ['whatsapp', 'notificaciones', 'reservas_online', 'recursos',
|
||||||
|
'membresias', 'compras', 'comisiones', 'formularios']
|
||||||
|
MODULOS_VALIDOS = MODULOS_BASE + MODULOS_GATEABLES
|
||||||
|
|
||||||
|
PLANES = {
|
||||||
|
'basico': MODULOS_BASE,
|
||||||
|
'profesional': MODULOS_BASE + ['whatsapp', 'notificaciones',
|
||||||
|
'reservas_online', 'recursos'],
|
||||||
|
'enterprise': MODULOS_BASE + MODULOS_GATEABLES,
|
||||||
|
'custom': [], # lo que diga --modulos; vacío = todos
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cargar_vendor(secreto=None, archivo=None):
|
||||||
|
if secreto:
|
||||||
|
return secreto.strip()
|
||||||
|
env = (os.environ.get(VENDOR_KEY_ENV) or '').strip()
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
ruta = archivo or VENDOR_KEY_FILE
|
||||||
|
try:
|
||||||
|
with open(ruta, 'r', encoding='utf-8') as fh:
|
||||||
|
return (fh.read() or '').strip()
|
||||||
|
except OSError:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
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(payload, vendor):
|
||||||
|
cuerpo = _b64url(_payload_canonico(payload))
|
||||||
|
firma = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
|
||||||
|
hashlib.sha256).digest()
|
||||||
|
return '%s.%s.%s' % (PREFIJO, cuerpo, _b64url(firma))
|
||||||
|
|
||||||
|
|
||||||
|
def verificar(clave, vendor):
|
||||||
|
partes = (clave or '').strip().split('.')
|
||||||
|
if len(partes) != 3 or partes[0] != PREFIJO:
|
||||||
|
return None, 'Formato de clave inválido.'
|
||||||
|
_, 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, 'Firma corrupta.'
|
||||||
|
if not hmac.compare_digest(esperada, recibida):
|
||||||
|
return None, 'La firma no es válida (clave manipulada).'
|
||||||
|
try:
|
||||||
|
payload = json.loads(_b64url_decode(cuerpo).decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return None, 'Contenido corrupto.'
|
||||||
|
vence = str(payload.get('vence') or '').strip()
|
||||||
|
if vence:
|
||||||
|
try:
|
||||||
|
if date.fromisoformat(vence) < date.today():
|
||||||
|
return payload, 'La licencia está vencida (venció el %s).' % vence
|
||||||
|
except ValueError:
|
||||||
|
return payload, 'Fecha de vencimiento inválida.'
|
||||||
|
return payload, None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_generar(args):
|
||||||
|
vendor = cargar_vendor(args.secreto, args.secreto_archivo)
|
||||||
|
if not vendor:
|
||||||
|
print('ERROR: no hay clave de vendedor. Configura %s o el archivo %s.'
|
||||||
|
% (VENDOR_KEY_ENV, VENDOR_KEY_FILE), file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
plan = (args.plan or 'custom').strip().lower()
|
||||||
|
if plan not in PLANES:
|
||||||
|
print('ERROR: plan inválido (basico|profesional|enterprise|custom).',
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.modulos:
|
||||||
|
modulos = []
|
||||||
|
for m in args.modulos.split(','):
|
||||||
|
m = m.strip().lower()
|
||||||
|
if m not in MODULOS_VALIDOS:
|
||||||
|
print('ERROR: módulo desconocido: %s' % m, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if m not in modulos:
|
||||||
|
modulos.append(m)
|
||||||
|
else:
|
||||||
|
modulos = list(PLANES[plan])
|
||||||
|
limites = {
|
||||||
|
'usuarios_max': args.usuarios_max or 0,
|
||||||
|
'sucursales_max': args.sucursales_max or 0,
|
||||||
|
'citas_mes_max': args.citas_mes_max or 0,
|
||||||
|
'almacenamiento_mb': args.almacenamiento_mb or 0,
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
'v': 1,
|
||||||
|
'cliente': args.cliente or '',
|
||||||
|
'plan': plan,
|
||||||
|
'vence': (args.vence or '').strip(),
|
||||||
|
'emitida': date.today().isoformat(),
|
||||||
|
'modulos': modulos,
|
||||||
|
'limites': limites,
|
||||||
|
}
|
||||||
|
clave = firmar(payload, vendor)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({'clave': clave, 'payload': payload},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
print(clave)
|
||||||
|
print('', file=sys.stderr)
|
||||||
|
print('Cliente: %s | Plan: %s | Vence: %s'
|
||||||
|
% (payload['cliente'] or '-', plan, payload['vence'] or 'sin vencimiento'),
|
||||||
|
file=sys.stderr)
|
||||||
|
print('Módulos: %s' % (', '.join(modulos) or 'todos'), file=sys.stderr)
|
||||||
|
print('Límites: %s' % limites, file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_verificar(args):
|
||||||
|
vendor = cargar_vendor(args.secreto, args.secreto_archivo)
|
||||||
|
if not vendor:
|
||||||
|
print('ERROR: no hay clave de vendedor.', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
payload, error = verificar(args.clave, vendor)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({'valida': error is None, 'error': error,
|
||||||
|
'payload': payload}, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
print('VÁLIDA' if error is None else 'INVÁLIDA: %s' % error)
|
||||||
|
if payload:
|
||||||
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
return 0 if error is None else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description='Generador de licencias SKEEN')
|
||||||
|
parser.add_argument('--secreto', help='clave del vendedor (o env/archivo)')
|
||||||
|
parser.add_argument('--secreto-archivo', dest='secreto_archivo',
|
||||||
|
help='ruta alterna del archivo con la clave del vendedor')
|
||||||
|
sub = parser.add_subparsers(dest='cmd', required=True)
|
||||||
|
|
||||||
|
g = sub.add_parser('generar', help='emite una clave de licencia')
|
||||||
|
g.add_argument('--cliente', default='')
|
||||||
|
g.add_argument('--plan', default='custom',
|
||||||
|
help='basico | profesional | enterprise | custom')
|
||||||
|
g.add_argument('--vence', default='', help='YYYY-MM-DD (vacío = sin vencimiento)')
|
||||||
|
g.add_argument('--modulos', default='', help='lista separada por comas')
|
||||||
|
g.add_argument('--usuarios-max', dest='usuarios_max', type=int, default=0)
|
||||||
|
g.add_argument('--sucursales-max', dest='sucursales_max', type=int, default=0)
|
||||||
|
g.add_argument('--citas-mes-max', dest='citas_mes_max', type=int, default=0)
|
||||||
|
g.add_argument('--almacenamiento-mb', dest='almacenamiento_mb', type=int, default=0)
|
||||||
|
g.add_argument('--json', action='store_true')
|
||||||
|
g.set_defaults(func=cmd_generar)
|
||||||
|
|
||||||
|
v = sub.add_parser('verificar', help='verifica una clave de licencia')
|
||||||
|
v.add_argument('--clave', required=True)
|
||||||
|
v.add_argument('--json', action='store_true')
|
||||||
|
v.set_defaults(func=cmd_verificar)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -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.
|
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 json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
from datetime import date
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
SETTINGS_KEY = 'skeen.clinic.settings'
|
SETTINGS_KEY = 'skeen.clinic.settings'
|
||||||
@@ -855,6 +860,385 @@ def get_comisiones(env):
|
|||||||
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
|
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
|
||||||
return normalizar_comisiones(get_settings(env).get('comisiones'))
|
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).
|
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
||||||
AGENDA_START_DEFAULT = 8.0
|
AGENDA_START_DEFAULT = 8.0
|
||||||
AGENDA_END_DEFAULT = 19.0
|
AGENDA_END_DEFAULT = 19.0
|
||||||
@@ -879,8 +1263,12 @@ def brand_nombre(env):
|
|||||||
return nombre or BRAND_NOMBRE_DEFAULT
|
return nombre or BRAND_NOMBRE_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
def get_settings(env):
|
def get_settings(env, aplicar_licencia=True):
|
||||||
"""Objeto completo de ajustes, con defaults si no hay nada guardado."""
|
"""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 = {
|
out = {
|
||||||
'nombre': brand_nombre(env),
|
'nombre': brand_nombre(env),
|
||||||
'telefono': '',
|
'telefono': '',
|
||||||
@@ -901,6 +1289,7 @@ def get_settings(env):
|
|||||||
'recursos': dict(RECURSOS_DEFAULT),
|
'recursos': dict(RECURSOS_DEFAULT),
|
||||||
'compras': dict(COMPRAS_DEFAULT),
|
'compras': dict(COMPRAS_DEFAULT),
|
||||||
'comisiones': dict(COMISIONES_DEFAULT),
|
'comisiones': dict(COMISIONES_DEFAULT),
|
||||||
|
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
|
||||||
}
|
}
|
||||||
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
||||||
if raw:
|
if raw:
|
||||||
@@ -919,6 +1308,9 @@ def get_settings(env):
|
|||||||
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
|
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
|
||||||
out['compras'] = normalizar_compras(guardado.get('compras'))
|
out['compras'] = normalizar_compras(guardado.get('compras'))
|
||||||
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
|
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
|
||||||
|
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
|
||||||
|
if aplicar_licencia:
|
||||||
|
_aplicar_licencia(out)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -926,7 +1318,7 @@ def set_settings(env, vals):
|
|||||||
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
|
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
|
||||||
if not isinstance(vals, dict):
|
if not isinstance(vals, dict):
|
||||||
raise ValueError('Los ajustes deben ser un objeto')
|
raise ValueError('Los ajustes deben ser un objeto')
|
||||||
actual = get_settings(env)
|
actual = get_settings(env, aplicar_licencia=False)
|
||||||
for k in CAMPOS:
|
for k in CAMPOS:
|
||||||
if k in vals and vals[k] is not None:
|
if k in vals and vals[k] is not None:
|
||||||
actual[k] = str(vals[k]).strip()
|
actual[k] = str(vals[k]).strip()
|
||||||
@@ -961,6 +1353,14 @@ def set_settings(env, vals):
|
|||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||||
actual['comisiones'] = {**actual['comisiones'], **bloque}
|
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(
|
env['ir.config_parameter'].sudo().set_param(
|
||||||
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
||||||
return get_settings(env)
|
return get_settings(env)
|
||||||
|
|||||||
16
odoo-addons/skeen_plataforma/__init__.py
Normal file
16
odoo-addons/skeen_plataforma/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|
||||||
|
|
||||||
|
def post_init_hook(env):
|
||||||
|
"""Siembra la clave legacy de Hermes si no existe.
|
||||||
|
|
||||||
|
En ambas instancias se siembra con el valor histórico
|
||||||
|
`skeen_api_key_2026` para no romper a Hermes al desplegar. Si la clínica ya
|
||||||
|
la cambió (o la borró), no se toca.
|
||||||
|
"""
|
||||||
|
ICP = env['ir.config_parameter'].sudo()
|
||||||
|
if not ICP.get_param('skeen.api.legacy_key'):
|
||||||
|
ICP.set_param('skeen.api.legacy_key', 'skeen_api_key_2026')
|
||||||
38
odoo-addons/skeen_plataforma/__manifest__.py
Normal file
38
odoo-addons/skeen_plataforma/__manifest__.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'SKEEN Plataforma (licencias, API keys y webhooks)',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'category': 'Healthcare',
|
||||||
|
'summary': 'Licenciamiento por plan, claves de API gestionables y webhooks salientes',
|
||||||
|
'description': """
|
||||||
|
Piezas de producto comercial para SKEEN:
|
||||||
|
|
||||||
|
* Licenciamiento por plan: el bloque `licencia` de clinic-settings decide
|
||||||
|
qué módulos incluye cada cliente y sus límites de uso. Sin licencia
|
||||||
|
aplicada (default) el sistema se comporta EXACTAMENTE como hoy.
|
||||||
|
* API de terceros gestionable: clave legacy de Hermes intacta + claves
|
||||||
|
propias con alcances (scopes), vencimiento y revocación.
|
||||||
|
* Webhooks salientes: outbox + cron + reintentos con backoff + log de
|
||||||
|
entregas. Con cero webhooks activos no se emite ni se envía nada.
|
||||||
|
|
||||||
|
Todo está apagado por defecto: sin licencia, sin claves nuevas y sin
|
||||||
|
webhooks configurados nada cambia en la instancia.
|
||||||
|
""",
|
||||||
|
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||||
|
'website': 'https://skeen.mx',
|
||||||
|
'depends': [
|
||||||
|
'base', 'skeen_citas', 'skeen_pacientes', 'skeen_visitas',
|
||||||
|
'skeen_ventas', 'skeen_pagos', 'skeen_whatsapp', 'skeen_inventario',
|
||||||
|
'skeen_membresias', 'skeen_compras', 'skeen_comisiones',
|
||||||
|
'skeen_formularios',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/webhooks_cron.xml',
|
||||||
|
],
|
||||||
|
'post_init_hook': 'post_init_hook',
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
5
odoo-addons/skeen_plataforma/controllers/__init__.py
Normal file
5
odoo-addons/skeen_plataforma/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import licencia
|
||||||
|
from . import api_keys
|
||||||
|
from . import webhooks
|
||||||
125
odoo-addons/skeen_plataforma/controllers/api_keys.py
Normal file
125
odoo-addons/skeen_plataforma/controllers/api_keys.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""CRUD de claves de API de terceros (Configuración -> API)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, require_perm
|
||||||
|
from odoo.addons.skeen_plataforma.models.api_key import SCOPES
|
||||||
|
|
||||||
|
BASE = '/skeen/frontend/v1'
|
||||||
|
|
||||||
|
|
||||||
|
def _json(data, status=200):
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, default=str),
|
||||||
|
status=status,
|
||||||
|
mimetype='application/json',
|
||||||
|
headers={
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _body():
|
||||||
|
try:
|
||||||
|
raw = request.httprequest.get_data(as_text=True)
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _login():
|
||||||
|
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenApiKeysController(http.Controller):
|
||||||
|
|
||||||
|
@http.route(BASE + '/api_keys', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('api.claves')
|
||||||
|
def list_api_keys(self, **kw):
|
||||||
|
try:
|
||||||
|
keys = request.env['skeen.api.key'].sudo().search([])
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'api_keys': [k.to_public_dict() for k in keys],
|
||||||
|
'scopes': [{'clave': c, 'label': l} for c, l in SCOPES],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/api_keys', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('api.claves')
|
||||||
|
def create_api_key(self, **kw):
|
||||||
|
try:
|
||||||
|
data = _body()
|
||||||
|
nombre = (data.get('name') or '').strip()
|
||||||
|
if not nombre:
|
||||||
|
return _json({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
|
||||||
|
key, raw = request.env['skeen.api.key'].sudo().generar({
|
||||||
|
'name': nombre,
|
||||||
|
'scopes': data.get('scopes') or [],
|
||||||
|
'vence': data.get('vence') or False,
|
||||||
|
'notas': data.get('notas') or '',
|
||||||
|
}, login=_login())
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'clave': raw, # se muestra UNA sola vez
|
||||||
|
'api_key': key.to_public_dict(),
|
||||||
|
'message': 'Guarda esta clave: no se volverá a mostrar.',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/api_keys/<int:key_id>', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('api.claves')
|
||||||
|
def update_api_key(self, key_id, **kw):
|
||||||
|
try:
|
||||||
|
key = request.env['skeen.api.key'].sudo().browse(key_id)
|
||||||
|
if not key.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Clave no encontrada'}, 404)
|
||||||
|
data = _body()
|
||||||
|
vals = {}
|
||||||
|
if 'name' in data and (data.get('name') or '').strip():
|
||||||
|
vals['name'] = data['name'].strip()
|
||||||
|
if 'scopes' in data:
|
||||||
|
vals['scopes'] = json.dumps(key._normalizar_scopes(data.get('scopes') or []))
|
||||||
|
if 'activo' in data:
|
||||||
|
vals['activo'] = bool(data['activo'])
|
||||||
|
if 'vence' in data:
|
||||||
|
vals['vence'] = data.get('vence') or False
|
||||||
|
if 'notas' in data:
|
||||||
|
vals['notas'] = data.get('notas') or ''
|
||||||
|
if vals:
|
||||||
|
key.write(vals)
|
||||||
|
return _json({'status': 'success', 'api_key': key.to_public_dict()})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/api_keys/<int:key_id>/revocar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('api.claves')
|
||||||
|
def revocar_api_key(self, key_id, **kw):
|
||||||
|
try:
|
||||||
|
key = request.env['skeen.api.key'].sudo().browse(key_id)
|
||||||
|
if not key.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Clave no encontrada'}, 404)
|
||||||
|
key.action_revocar(login=_login())
|
||||||
|
return _json({'status': 'success', 'message': 'Clave revocada.',
|
||||||
|
'api_key': key.to_public_dict()})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
219
odoo-addons/skeen_plataforma/controllers/licencia.py
Normal file
219
odoo-addons/skeen_plataforma/controllers/licencia.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""API de licencia por plan (Configuración -> Licencia).
|
||||||
|
|
||||||
|
Solo administración del sistema (PUT) y administración/gerencia (GET/validar).
|
||||||
|
Nunca bloquea el login, la agenda ni la configuración.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import fields, http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, current_user
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import (
|
||||||
|
PLANES, MODULOS_BASE, MODULOS_GATEABLES, MODULO_LABEL, LIMITE_KEYS,
|
||||||
|
get_settings, set_settings, licencia_de, modulos_licencia,
|
||||||
|
licencia_bloqueo_activo, verificar_clave_licencia, licencia_desde_payload,
|
||||||
|
dias_para_vencer, LICENCIA_DEFAULT,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE = '/skeen/frontend/v1'
|
||||||
|
LIMITE_LABEL = {
|
||||||
|
'usuarios_max': 'Usuarios',
|
||||||
|
'sucursales_max': 'Sucursales',
|
||||||
|
'citas_mes_max': 'Citas del mes',
|
||||||
|
'almacenamiento_mb': 'Almacenamiento (MB)',
|
||||||
|
}
|
||||||
|
AVISO_DIAS = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _json(data, status=200):
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, default=str),
|
||||||
|
status=status,
|
||||||
|
mimetype='application/json',
|
||||||
|
headers={
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _body():
|
||||||
|
try:
|
||||||
|
raw = request.httprequest.get_data(as_text=True)
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _puede_ver():
|
||||||
|
u = current_user(request.env)
|
||||||
|
return bool(u and u.role in ('admin', 'gerencia'))
|
||||||
|
|
||||||
|
|
||||||
|
def _puede_editar():
|
||||||
|
u = current_user(request.env)
|
||||||
|
return bool(u and u.role == 'admin')
|
||||||
|
|
||||||
|
|
||||||
|
def _uso(env):
|
||||||
|
"""Uso actual de cada límite frente a su tope."""
|
||||||
|
from datetime import date
|
||||||
|
hoy = date.today()
|
||||||
|
inicio = hoy.replace(day=1)
|
||||||
|
fin = hoy.replace(year=hoy.year + 1, month=1, day=1) if hoy.month == 12 \
|
||||||
|
else hoy.replace(month=hoy.month + 1, day=1)
|
||||||
|
env.cr.execute('SELECT COALESCE(SUM(file_size), 0) FROM ir_attachment')
|
||||||
|
bytes_almacen = env.cr.fetchone()[0] or 0
|
||||||
|
return {
|
||||||
|
'usuarios_max': env['skeen.frontend.user'].sudo().search_count([('active', '=', True)]),
|
||||||
|
'sucursales_max': env['skeen.sucursal'].sudo().search_count([('active', '=', True)]),
|
||||||
|
'citas_mes_max': env['skeen.cita'].sudo().search_count(
|
||||||
|
[('date', '>=', inicio), ('date', '<', fin)]),
|
||||||
|
'almacenamiento_mb': round(bytes_almacen / (1024.0 * 1024.0), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _estado(env):
|
||||||
|
settings = get_settings(env)
|
||||||
|
licencia = settings.get('licencia') or dict(LICENCIA_DEFAULT)
|
||||||
|
limites = licencia.get('limites') or {}
|
||||||
|
uso = _uso(env)
|
||||||
|
filas = []
|
||||||
|
for clave in LIMITE_KEYS:
|
||||||
|
tope = int(limites.get(clave) or 0)
|
||||||
|
actual = uso.get(clave) or 0
|
||||||
|
if tope <= 0:
|
||||||
|
pct = 0
|
||||||
|
estado = 'sin_limite'
|
||||||
|
else:
|
||||||
|
pct = round(min(100.0, (actual / float(tope)) * 100.0), 1)
|
||||||
|
estado = 'excedido' if actual > tope else (
|
||||||
|
'cerca' if pct >= 80 else 'ok')
|
||||||
|
filas.append({
|
||||||
|
'clave': clave,
|
||||||
|
'label': LIMITE_LABEL.get(clave, clave),
|
||||||
|
'uso': actual,
|
||||||
|
'limite': tope,
|
||||||
|
'porcentaje': pct,
|
||||||
|
'estado': estado,
|
||||||
|
})
|
||||||
|
permitidos = modulos_licencia(licencia)
|
||||||
|
modulos = []
|
||||||
|
for clave in list(MODULOS_BASE) + list(MODULOS_GATEABLES):
|
||||||
|
incluido = permitidos is None or clave in permitidos
|
||||||
|
modulos.append({
|
||||||
|
'clave': clave,
|
||||||
|
'label': MODULO_LABEL.get(clave, clave),
|
||||||
|
'base': clave in MODULOS_BASE,
|
||||||
|
'incluido': incluido,
|
||||||
|
})
|
||||||
|
dias = dias_para_vencer(licencia)
|
||||||
|
aplicada = bool(licencia.get('clave'))
|
||||||
|
return {
|
||||||
|
'licencia': {
|
||||||
|
'plan': licencia.get('plan') or '',
|
||||||
|
'plan_label': (PLANES.get(licencia.get('plan')) or {}).get('label', ''),
|
||||||
|
'modo': licencia.get('modo') or 'aviso',
|
||||||
|
'cliente': licencia.get('cliente') or '',
|
||||||
|
'vence': licencia.get('vence') or '',
|
||||||
|
'dias_restantes': dias,
|
||||||
|
'aplicada': aplicada,
|
||||||
|
'bloqueo_activo': licencia_bloqueo_activo(licencia),
|
||||||
|
'aviso_vence': bool(dias is not None and 0 <= dias <= AVISO_DIAS),
|
||||||
|
},
|
||||||
|
'limites': filas,
|
||||||
|
'modulos': modulos,
|
||||||
|
'planes': [{'clave': k, 'label': v['label'], 'modulos': v['modulos']}
|
||||||
|
for k, v in PLANES.items()],
|
||||||
|
'aviso': ('Sin licencia aplicada — todas las funciones habilitadas.'
|
||||||
|
if not aplicada else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenLicenciaController(http.Controller):
|
||||||
|
|
||||||
|
@http.route(BASE + '/licencia', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
def get_licencia(self, **kw):
|
||||||
|
try:
|
||||||
|
if str(request.httprequest.method).upper() == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not _puede_ver():
|
||||||
|
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
return _json({'status': 'success', **_estado(request.env)})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/licencia', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
def put_licencia(self, **kw):
|
||||||
|
try:
|
||||||
|
if str(request.httprequest.method).upper() == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not _puede_editar():
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Solo administración puede aplicar licencias'}, 403)
|
||||||
|
data = _body()
|
||||||
|
env = request.env
|
||||||
|
if 'clave' in data:
|
||||||
|
clave = (data.get('clave') or '').strip()
|
||||||
|
if not clave:
|
||||||
|
# Quitar la licencia: todo vuelve a estar habilitado.
|
||||||
|
vacio = dict(LICENCIA_DEFAULT)
|
||||||
|
vacio['limites'] = {k: 0 for k in LIMITE_KEYS}
|
||||||
|
set_settings(env, {'licencia': vacio})
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'message': 'Licencia retirada. Todo habilitado.',
|
||||||
|
**_estado(env)})
|
||||||
|
payload, error = verificar_clave_licencia(env, clave)
|
||||||
|
if error:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': '%s No se guardó ningún cambio.' % error}, 400)
|
||||||
|
modo = data.get('modo') or 'bloqueo'
|
||||||
|
lic = licencia_desde_payload(payload, clave, modo=modo)
|
||||||
|
set_settings(env, {'licencia': lic})
|
||||||
|
return _json({'status': 'success', 'message': 'Licencia aplicada.',
|
||||||
|
**_estado(env)})
|
||||||
|
# Cambios sin re-firmar (p. ej. pasar de aviso a bloqueo).
|
||||||
|
vals = {k: data[k] for k in ('modo', 'plan', 'cliente', 'vence',
|
||||||
|
'modulos', 'limites') if k in data}
|
||||||
|
if not vals:
|
||||||
|
return _json({'status': 'error', 'message': 'Nada que actualizar'}, 400)
|
||||||
|
set_settings(env, {'licencia': vals})
|
||||||
|
return _json({'status': 'success', 'message': 'Licencia actualizada.',
|
||||||
|
**_estado(env)})
|
||||||
|
except ValueError as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/licencia/validar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
def validar_licencia(self, **kw):
|
||||||
|
try:
|
||||||
|
if str(request.httprequest.method).upper() == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not _puede_ver():
|
||||||
|
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
data = _body()
|
||||||
|
payload, error = verificar_clave_licencia(request.env, data.get('clave'))
|
||||||
|
dias = dias_para_vencer({'vence': (payload or {}).get('vence')})
|
||||||
|
return _json({
|
||||||
|
'status': 'success' if not error else 'error',
|
||||||
|
'valida': error is None,
|
||||||
|
'error': error,
|
||||||
|
'payload': payload,
|
||||||
|
'dias_restantes': dias,
|
||||||
|
'aviso_vence': bool(dias is not None and 0 <= dias <= AVISO_DIAS),
|
||||||
|
}, 200 if not error else 400)
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
225
odoo-addons/skeen_plataforma/controllers/webhooks.py
Normal file
225
odoo-addons/skeen_plataforma/controllers/webhooks.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""CRUD de webhooks salientes y log de entregas (Configuración -> Webhooks)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, require_perm
|
||||||
|
from odoo.addons.skeen_plataforma.models.webhook import EVENTOS, ESTADOS
|
||||||
|
|
||||||
|
BASE = '/skeen/frontend/v1'
|
||||||
|
|
||||||
|
|
||||||
|
def _json(data, status=200):
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, default=str),
|
||||||
|
status=status,
|
||||||
|
mimetype='application/json',
|
||||||
|
headers={
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _body():
|
||||||
|
try:
|
||||||
|
raw = request.httprequest.get_data(as_text=True)
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _login():
|
||||||
|
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _eventos_list(data):
|
||||||
|
if isinstance(data.get('eventos'), list):
|
||||||
|
return [str(e).strip() for e in data['eventos'] if str(e).strip()]
|
||||||
|
if isinstance(data.get('eventos'), str):
|
||||||
|
return [e.strip() for e in data['eventos'].split(',') if e.strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenWebhooksController(http.Controller):
|
||||||
|
|
||||||
|
@http.route(BASE + '/webhooks', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('webhooks.editar')
|
||||||
|
def list_webhooks(self, **kw):
|
||||||
|
try:
|
||||||
|
service = request.env['skeen.webhook.service'].sudo()
|
||||||
|
hooks = request.env['skeen.webhook'].sudo().search([])
|
||||||
|
catalogo = [{'clave': c, 'label': l,
|
||||||
|
'ejemplo': service.payload_ejemplo(c)} for c, l in EVENTOS]
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'webhooks': [w.to_public_dict() for w in hooks],
|
||||||
|
'eventos': catalogo,
|
||||||
|
'estados': [{'clave': c, 'label': l} for c, l in ESTADOS],
|
||||||
|
'formato_firma': 'X-Skeen-Signature: sha256=<HMAC-SHA256 del cuerpo con el secreto>',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/webhooks', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('webhooks.editar')
|
||||||
|
def create_webhook(self, **kw):
|
||||||
|
try:
|
||||||
|
data = _body()
|
||||||
|
url = (data.get('url') or '').strip()
|
||||||
|
if not (url.startswith('http://') or url.startswith('https://')):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'La URL debe empezar por http:// o https://'}, 400)
|
||||||
|
secreto = (data.get('secreto') or '').strip() or secrets.token_urlsafe(32)
|
||||||
|
headers = data.get('headers')
|
||||||
|
if isinstance(headers, dict):
|
||||||
|
headers = json.dumps(headers, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
headers = (headers or '').strip()
|
||||||
|
webhook = request.env['skeen.webhook'].sudo().create({
|
||||||
|
'name': (data.get('name') or '').strip() or 'Webhook',
|
||||||
|
'url': url,
|
||||||
|
'eventos': ','.join(_eventos_list(data)),
|
||||||
|
'activo': bool(data.get('activo', True)),
|
||||||
|
'secreto': secreto,
|
||||||
|
'headers': headers,
|
||||||
|
'notas': data.get('notas') or '',
|
||||||
|
'creado_por_login': _login(),
|
||||||
|
})
|
||||||
|
return _json({'status': 'success', 'webhook': webhook.to_public_dict(),
|
||||||
|
'secreto': secreto,
|
||||||
|
'message': 'Webhook creado.'})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/webhooks/<int:webhook_id>', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('webhooks.editar')
|
||||||
|
def update_webhook(self, webhook_id, **kw):
|
||||||
|
try:
|
||||||
|
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
|
||||||
|
if not webhook.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
|
||||||
|
data = _body()
|
||||||
|
vals = {}
|
||||||
|
if 'name' in data and (data.get('name') or '').strip():
|
||||||
|
vals['name'] = data['name'].strip()
|
||||||
|
if 'url' in data and (data.get('url') or '').strip():
|
||||||
|
url = data['url'].strip()
|
||||||
|
if not (url.startswith('http://') or url.startswith('https://')):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'La URL debe empezar por http:// o https://'}, 400)
|
||||||
|
vals['url'] = url
|
||||||
|
if 'eventos' in data:
|
||||||
|
vals['eventos'] = ','.join(_eventos_list(data))
|
||||||
|
if 'activo' in data:
|
||||||
|
vals['activo'] = bool(data['activo'])
|
||||||
|
if vals['activo']:
|
||||||
|
vals['fallos_consecutivos'] = 0
|
||||||
|
if 'secreto' in data and (data.get('secreto') or '').strip():
|
||||||
|
vals['secreto'] = data['secreto'].strip()
|
||||||
|
if 'headers' in data:
|
||||||
|
headers = data['headers']
|
||||||
|
vals['headers'] = json.dumps(headers, ensure_ascii=False) \
|
||||||
|
if isinstance(headers, dict) else (headers or '')
|
||||||
|
if 'notas' in data:
|
||||||
|
vals['notas'] = data.get('notas') or ''
|
||||||
|
if vals:
|
||||||
|
webhook.write(vals)
|
||||||
|
return _json({'status': 'success', 'webhook': webhook.to_public_dict()})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/webhooks/<int:webhook_id>', type='http', auth='none',
|
||||||
|
methods=['DELETE'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('webhooks.editar')
|
||||||
|
def delete_webhook(self, webhook_id, **kw):
|
||||||
|
try:
|
||||||
|
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
|
||||||
|
if not webhook.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
|
||||||
|
webhook.unlink()
|
||||||
|
return _json({'status': 'success', 'message': 'Webhook eliminado.'})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/webhooks/<int:webhook_id>/eventos', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('webhooks.editar')
|
||||||
|
def list_webhook_eventos(self, webhook_id, **kw):
|
||||||
|
try:
|
||||||
|
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
|
||||||
|
if not webhook.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
|
||||||
|
domain = [('webhook_id', '=', webhook.id)]
|
||||||
|
if kw.get('estado'):
|
||||||
|
domain.append(('estado', '=', kw['estado']))
|
||||||
|
if kw.get('evento'):
|
||||||
|
domain.append(('evento', '=', kw['evento']))
|
||||||
|
try:
|
||||||
|
limite = min(max(int(kw.get('limit') or 100), 1), 500)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limite = 100
|
||||||
|
Evento = request.env['skeen.webhook.evento'].sudo()
|
||||||
|
registros = Evento.search(domain, order='create_date desc, id desc',
|
||||||
|
limit=limite)
|
||||||
|
resumen = {c: 0 for c, _l in ESTADOS}
|
||||||
|
for grupo in Evento.read_group(domain, ['estado'], ['estado'], lazy=False):
|
||||||
|
if grupo.get('estado'):
|
||||||
|
resumen[grupo['estado']] = grupo.get('__count', 0)
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'eventos': [e.to_public_dict() for e in registros],
|
||||||
|
'total': Evento.search_count(domain),
|
||||||
|
'resumen': resumen})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/webhooks/<int:webhook_id>/probar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('webhooks.editar')
|
||||||
|
def probar_webhook(self, webhook_id, **kw):
|
||||||
|
try:
|
||||||
|
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
|
||||||
|
if not webhook.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
|
||||||
|
ok, mensaje, evento = request.env['skeen.webhook.service'].sudo().probar(
|
||||||
|
webhook, login=_login())
|
||||||
|
return _json({'status': 'success' if ok else 'error', 'ok': ok,
|
||||||
|
'message': mensaje,
|
||||||
|
'evento': evento.to_public_dict() if evento else None},
|
||||||
|
200 if ok else 502)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/webhook_eventos/<int:evento_id>/reintentar', type='http',
|
||||||
|
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('gerencia')
|
||||||
|
@require_perm('webhooks.editar')
|
||||||
|
def reintentar_evento(self, evento_id, **kw):
|
||||||
|
try:
|
||||||
|
evento = request.env['skeen.webhook.evento'].sudo().browse(evento_id)
|
||||||
|
if not evento.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Evento no encontrado'}, 404)
|
||||||
|
estado = request.env['skeen.webhook.service'].sudo().reintentar(evento)
|
||||||
|
return _json({'status': 'success', 'estado': estado,
|
||||||
|
'evento': evento.to_public_dict()})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
17
odoo-addons/skeen_plataforma/data/webhooks_cron.xml
Normal file
17
odoo-addons/skeen_plataforma/data/webhooks_cron.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- Entrega de webhooks salientes. Sin eventos pendientes el cron no
|
||||||
|
hace nada; sin webhooks activos no se encola ningún evento. -->
|
||||||
|
<record id="cron_webhooks" model="ir.cron">
|
||||||
|
<field name="name">SKEEN Webhooks: entrega de eventos pendientes</field>
|
||||||
|
<field name="model_id" ref="model_skeen_webhook_service"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_tick()</field>
|
||||||
|
<field name="interval_number">5</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
7
odoo-addons/skeen_plataforma/models/__init__.py
Normal file
7
odoo-addons/skeen_plataforma/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import ir_http
|
||||||
|
from . import api_key
|
||||||
|
from . import webhook
|
||||||
|
from . import limites
|
||||||
|
from . import eventos
|
||||||
168
odoo-addons/skeen_plataforma/models/api_key.py
Normal file
168
odoo-addons/skeen_plataforma/models/api_key.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Claves de API de terceros (`skeen.api.key`).
|
||||||
|
|
||||||
|
La clave se muestra UNA sola vez al crearla: solo se guarda su hash SHA-256 y
|
||||||
|
un prefijo visible para identificarla. La comparación es en tiempo constante.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
# Alcances disponibles (vacío = todos).
|
||||||
|
SCOPES = [
|
||||||
|
('citas.read', 'Consultar citas y disponibilidad'),
|
||||||
|
('citas.write', 'Crear y cancelar citas'),
|
||||||
|
('pacientes.read', 'Consultar pacientes y saldos'),
|
||||||
|
('servicios.read', 'Consultar el catálogo de servicios'),
|
||||||
|
('monedero.write', 'Redimir puntos del monedero'),
|
||||||
|
]
|
||||||
|
SCOPE_KEYS = tuple(k for k, _l in SCOPES)
|
||||||
|
SCOPE_LABEL = dict(SCOPES)
|
||||||
|
|
||||||
|
PREFIJO_LARGO = 14 # caracteres visibles de la clave
|
||||||
|
|
||||||
|
|
||||||
|
def hash_clave(raw):
|
||||||
|
return hashlib.sha256((raw or '').encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenApiKey(models.Model):
|
||||||
|
_name = 'skeen.api.key'
|
||||||
|
_description = 'Clave de API de terceros'
|
||||||
|
_order = 'create_date desc, id desc'
|
||||||
|
|
||||||
|
name = fields.Char(string='Nombre', required=True)
|
||||||
|
prefijo = fields.Char(string='Prefijo visible', required=True, index=True)
|
||||||
|
hash = fields.Char(string='Hash', required=True)
|
||||||
|
activo = fields.Boolean(string='Activa', default=True)
|
||||||
|
scopes = fields.Char(string='Alcances', default='[]',
|
||||||
|
help='Lista JSON de alcances. Vacío = todos.')
|
||||||
|
vence = fields.Date(string='Vence')
|
||||||
|
ultimo_uso = fields.Datetime(string='Último uso')
|
||||||
|
usos = fields.Integer(string='Usos', default=0)
|
||||||
|
notas = fields.Text(string='Notas')
|
||||||
|
creado_por_login = fields.Char(string='Creada por')
|
||||||
|
revocada_at = fields.Datetime(string='Revocada el')
|
||||||
|
revocada_por = fields.Char(string='Revocada por')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('hash_uniq', 'unique(hash)', 'Ya existe una clave de API con ese hash.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def generar(self, vals, login=''):
|
||||||
|
"""Crea una clave y devuelve (record, clave_en_claro).
|
||||||
|
|
||||||
|
La clave en claro solo se devuelve aquí; no se persiste.
|
||||||
|
"""
|
||||||
|
raw = 'sk_' + secrets.token_urlsafe(32)
|
||||||
|
record = self.create({
|
||||||
|
'name': vals.get('name') or 'Clave de API',
|
||||||
|
'prefijo': raw[:PREFIJO_LARGO],
|
||||||
|
'hash': hash_clave(raw),
|
||||||
|
'scopes': json.dumps(self._normalizar_scopes(vals.get('scopes') or [])),
|
||||||
|
'vence': vals.get('vence') or False,
|
||||||
|
'notas': vals.get('notas') or '',
|
||||||
|
'creado_por_login': login or '',
|
||||||
|
'activo': True,
|
||||||
|
})
|
||||||
|
return record, raw
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _normalizar_scopes(self, scopes):
|
||||||
|
if not isinstance(scopes, (list, tuple)):
|
||||||
|
return []
|
||||||
|
limpios = []
|
||||||
|
for s in scopes:
|
||||||
|
s = str(s or '').strip()
|
||||||
|
if s and s in SCOPE_KEYS and s not in limpios:
|
||||||
|
limpios.append(s)
|
||||||
|
return limpios
|
||||||
|
|
||||||
|
def scopes_list(self):
|
||||||
|
try:
|
||||||
|
val = json.loads(self.scopes or '[]')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = []
|
||||||
|
return val if isinstance(val, list) else []
|
||||||
|
|
||||||
|
def scopes_label(self):
|
||||||
|
"""Texto para la UI: 'todos' o la lista de etiquetas."""
|
||||||
|
scopes = self.scopes_list()
|
||||||
|
if not scopes:
|
||||||
|
return 'Todos'
|
||||||
|
return ', '.join(SCOPE_LABEL.get(s, s) for s in scopes)
|
||||||
|
|
||||||
|
def permite(self, scope):
|
||||||
|
"""True si la clave concede el alcance (vacío = todos). Nunca si inactiva."""
|
||||||
|
if not self.activo:
|
||||||
|
return False
|
||||||
|
if self.vence and self.vence < fields.Date.today():
|
||||||
|
return False
|
||||||
|
scopes = self.scopes_list()
|
||||||
|
if not scopes:
|
||||||
|
return True
|
||||||
|
return scope in scopes
|
||||||
|
|
||||||
|
def estado(self):
|
||||||
|
if self.revocada_at or not self.activo:
|
||||||
|
return 'revocada'
|
||||||
|
if self.vence and self.vence < fields.Date.today():
|
||||||
|
return 'vencida'
|
||||||
|
return 'activa'
|
||||||
|
|
||||||
|
def to_public_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'prefijo': self.prefijo,
|
||||||
|
'activo': self.activo,
|
||||||
|
'scopes': self.scopes_list(),
|
||||||
|
'scopes_label': self.scopes_label(),
|
||||||
|
'vence': self.vence.strftime('%Y-%m-%d') if self.vence else '',
|
||||||
|
'ultimo_uso': self.ultimo_uso.strftime('%Y-%m-%d %H:%M') if self.ultimo_uso else '',
|
||||||
|
'usos': self.usos,
|
||||||
|
'notas': self.notas or '',
|
||||||
|
'creado_por_login': self.creado_por_login or '',
|
||||||
|
'revocada_at': self.revocada_at.strftime('%Y-%m-%d %H:%M') if self.revocada_at else '',
|
||||||
|
'revocada_por': self.revocada_por or '',
|
||||||
|
'estado': self.estado(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _autenticar(self, raw):
|
||||||
|
"""Devuelve la clave activa que corresponde a `raw` (o recordset vacío)."""
|
||||||
|
raw = (raw or '').strip()
|
||||||
|
if not raw:
|
||||||
|
return self.browse()
|
||||||
|
candidatas = self.search([
|
||||||
|
('prefijo', '=', raw[:PREFIJO_LARGO]), ('activo', '=', True),
|
||||||
|
])
|
||||||
|
digest = hash_clave(raw)
|
||||||
|
for key in candidatas:
|
||||||
|
if hmac.compare_digest(key.hash or '', digest):
|
||||||
|
return key
|
||||||
|
# Respaldo: por si el prefijo almacenado difiere, comparar por hash.
|
||||||
|
return self.search([('hash', '=', digest), ('activo', '=', True)], limit=1)
|
||||||
|
|
||||||
|
def _registrar_uso(self):
|
||||||
|
if not self:
|
||||||
|
return
|
||||||
|
self.sudo().write({
|
||||||
|
'ultimo_uso': fields.Datetime.now(),
|
||||||
|
'usos': self.usos + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
def action_revocar(self, login=''):
|
||||||
|
self.write({
|
||||||
|
'activo': False,
|
||||||
|
'revocada_at': fields.Datetime.now(),
|
||||||
|
'revocada_por': login or '',
|
||||||
|
})
|
||||||
193
odoo-addons/skeen_plataforma/models/eventos.py
Normal file
193
odoo-addons/skeen_plataforma/models/eventos.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Emisión de eventos de webhook desde los flujos existentes.
|
||||||
|
|
||||||
|
Cada override comprueba primero si hay algún webhook activo (una consulta
|
||||||
|
barata sobre una tabla normalmente vacía); si no lo hay, se comporta
|
||||||
|
EXACTAMENTE como antes y no crea ninguna fila. La entrega real es asíncrona
|
||||||
|
(cron), así que aquí solo se encola.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _emitir(env, evento, payload, clave_unica=None):
|
||||||
|
"""Encapsula la emisión para que NUNCA tumbe la operación de negocio."""
|
||||||
|
try:
|
||||||
|
service = env['skeen.webhook.service'].sudo()
|
||||||
|
if not service.hay_activos():
|
||||||
|
return
|
||||||
|
service.emitir(evento, payload, clave_unica=clave_unica)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo emitir el evento de webhook %s', evento)
|
||||||
|
|
||||||
|
|
||||||
|
def _val(record, campo):
|
||||||
|
valor = getattr(record, campo, None)
|
||||||
|
if hasattr(valor, 'id'): # many2one
|
||||||
|
return valor.id or False
|
||||||
|
if hasattr(valor, 'name'): # recordset
|
||||||
|
return valor.name
|
||||||
|
if hasattr(valor, 'isoformat'): # fecha/datetime
|
||||||
|
return valor.isoformat()
|
||||||
|
return valor
|
||||||
|
|
||||||
|
|
||||||
|
def _cita_payload(cita):
|
||||||
|
return {
|
||||||
|
'id': cita.id,
|
||||||
|
'reference': cita.name,
|
||||||
|
'state': cita.state,
|
||||||
|
'date': cita.date.isoformat() if cita.date else '',
|
||||||
|
'patient_id': cita.partner_id.id,
|
||||||
|
'patient': cita.partner_id.name or '',
|
||||||
|
'service_id': cita.servicio_id.id if cita.servicio_id else None,
|
||||||
|
'servicio': cita.servicio_id.name if cita.servicio_id else '',
|
||||||
|
'doctor': cita.doctor_id.name if getattr(cita, 'doctor_id', None) else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCita(models.Model):
|
||||||
|
_inherit = 'skeen.cita'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
citas = super().create(vals_list)
|
||||||
|
for cita in citas:
|
||||||
|
_emitir(self.env, 'cita.creada', _cita_payload(cita),
|
||||||
|
clave_unica='cita.creada:%s' % cita.id)
|
||||||
|
return citas
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if 'state' not in vals:
|
||||||
|
return super().write(vals)
|
||||||
|
previo = {c.id: c.state for c in self}
|
||||||
|
res = super().write(vals)
|
||||||
|
destinos = {
|
||||||
|
'confirmed': 'cita.confirmada',
|
||||||
|
'cancelled': 'cita.cancelada',
|
||||||
|
'no_show': 'cita.no_show',
|
||||||
|
'done': 'cita.completada',
|
||||||
|
}
|
||||||
|
for cita in self:
|
||||||
|
evento = destinos.get(cita.state)
|
||||||
|
if evento and previo.get(cita.id) != cita.state:
|
||||||
|
_emitir(self.env, evento, _cita_payload(cita),
|
||||||
|
clave_unica='%s:%s' % (evento, cita.id))
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenVenta(models.Model):
|
||||||
|
_inherit = 'skeen.venta'
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if 'state' not in vals and 'refunded' not in vals:
|
||||||
|
return super().write(vals)
|
||||||
|
previo = {v.id: (v.state, v.refunded) for v in self}
|
||||||
|
res = super().write(vals)
|
||||||
|
for venta in self:
|
||||||
|
antes = previo.get(venta.id, (None, None))
|
||||||
|
if venta.state == 'paid' and antes[0] != 'paid':
|
||||||
|
_emitir(self.env, 'venta.pagada', {
|
||||||
|
'id': venta.id, 'reference': venta.name, 'state': venta.state,
|
||||||
|
'total': _val(venta, 'total'), 'patient_id': venta.partner_id.id,
|
||||||
|
}, clave_unica='venta.pagada:%s' % venta.id)
|
||||||
|
if (venta.state == 'cancelled' and antes[0] != 'cancelled') or \
|
||||||
|
(venta.refunded and not antes[1]):
|
||||||
|
_emitir(self.env, 'venta.cancelada', {
|
||||||
|
'id': venta.id, 'reference': venta.name, 'state': venta.state,
|
||||||
|
'refunded': bool(venta.refunded), 'refund_amount': _val(venta, 'refund_amount'),
|
||||||
|
}, clave_unica='venta.cancelada:%s' % venta.id)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenPago(models.Model):
|
||||||
|
_inherit = 'skeen.pago'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
pagos = super().create(vals_list)
|
||||||
|
for pago in pagos:
|
||||||
|
_emitir(self.env, 'pago.registrado', {
|
||||||
|
'id': pago.id, 'reference': pago.name, 'amount': pago.amount,
|
||||||
|
'payment_method': pago.payment_method, 'state': pago.state,
|
||||||
|
'patient_id': pago.partner_id.id if pago.partner_id else None,
|
||||||
|
}, clave_unica='pago.registrado:%s' % pago.id)
|
||||||
|
return pagos
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
partners = super().create(vals_list)
|
||||||
|
for partner in partners:
|
||||||
|
if not partner.is_patient:
|
||||||
|
continue
|
||||||
|
_emitir(self.env, 'paciente.creado', {
|
||||||
|
'id': partner.id, 'name': partner.name,
|
||||||
|
'phone': partner.phone or '', 'email': partner.email or '',
|
||||||
|
'is_patient': True,
|
||||||
|
}, clave_unica='paciente.creado:%s' % partner.id)
|
||||||
|
return partners
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenMembresia(models.Model):
|
||||||
|
_inherit = 'skeen.membresia'
|
||||||
|
|
||||||
|
def action_renovar(self, *args, **kwargs):
|
||||||
|
res = super().action_renovar(*args, **kwargs)
|
||||||
|
for membresia in self:
|
||||||
|
_emitir(self.env, 'membresia.renovada', {
|
||||||
|
'id': membresia.id,
|
||||||
|
'plan': membresia.plan_id.name if membresia.plan_id else '',
|
||||||
|
'estado': membresia.estado,
|
||||||
|
'periodo_actual_inicio': _val(membresia, 'periodo_actual_inicio'),
|
||||||
|
'periodo_actual_fin': _val(membresia, 'periodo_actual_fin'),
|
||||||
|
'patient_id': membresia.partner_id.id if membresia.partner_id else None,
|
||||||
|
}, clave_unica='membresia.renovada:%s:%s'
|
||||||
|
% (membresia.id, _val(membresia, 'periodo_actual_inicio')))
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenFormularioRespuesta(models.Model):
|
||||||
|
_inherit = 'skeen.formulario.respuesta'
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if vals.get('estado') != 'llenado':
|
||||||
|
return super().write(vals)
|
||||||
|
previo = {r.id: r.estado for r in self}
|
||||||
|
res = super().write(vals)
|
||||||
|
for respuesta in self:
|
||||||
|
if previo.get(respuesta.id) != 'llenado':
|
||||||
|
_emitir(self.env, 'formulario.llenado', {
|
||||||
|
'id': respuesta.id,
|
||||||
|
'plantilla': _val(getattr(respuesta, 'plantilla_id', None), 'name')
|
||||||
|
if getattr(respuesta, 'plantilla_id', None) else '',
|
||||||
|
'estado': respuesta.estado,
|
||||||
|
'patient_id': respuesta.partner_id.id if getattr(respuesta, 'partner_id', None) else None,
|
||||||
|
}, clave_unica='formulario.llenado:%s' % respuesta.id)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCompra(models.Model):
|
||||||
|
_inherit = 'skeen.compra'
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
if 'estado' not in vals:
|
||||||
|
return super().write(vals)
|
||||||
|
previo = {c.id: c.estado for c in self}
|
||||||
|
res = super().write(vals)
|
||||||
|
for compra in self:
|
||||||
|
if compra.estado in ('recibida', 'recibida_parcial') \
|
||||||
|
and previo.get(compra.id) != compra.estado:
|
||||||
|
_emitir(self.env, 'compra.recibida', {
|
||||||
|
'id': compra.id,
|
||||||
|
'referencia': _val(compra, 'name') or _val(compra, 'referencia'),
|
||||||
|
'estado': compra.estado,
|
||||||
|
}, clave_unica='compra.recibida:%s:%s' % (compra.id, compra.estado))
|
||||||
|
return res
|
||||||
132
odoo-addons/skeen_plataforma/models/ir_http.py
Normal file
132
odoo-addons/skeen_plataforma/models/ir_http.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Autenticación `api_key` para las rutas públicas `/skeen/api/v1/*`.
|
||||||
|
|
||||||
|
Odoo no trae un método de autenticación `api_key`; las rutas de
|
||||||
|
`skeen_whatsapp/controllers/main.py` lo declaraban pero no existía, así que
|
||||||
|
TODAS las peticiones respondían AccessDenied. Aquí se implementa el método que
|
||||||
|
falta, sin cambiar ni el formato de la clave ni el de los encabezados:
|
||||||
|
|
||||||
|
1) CLave legacy de Hermes: `ir.config_parameter 'skeen.api.legacy_key'`
|
||||||
|
(sembrada con `skeen_api_key_2026`).
|
||||||
|
2) Claves del modelo `skeen.api.key` (hash, alcances, vencimiento, revocación).
|
||||||
|
|
||||||
|
La clave se lee de `Authorization: Bearer <clave>`, de `X-API-Key` o del
|
||||||
|
parámetro `api_key` (query/form), que es lo que ya usaba Hermes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
|
||||||
|
from werkzeug.exceptions import Forbidden
|
||||||
|
|
||||||
|
from odoo import SUPERUSER_ID, models, http
|
||||||
|
from odoo.exceptions import AccessDenied
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import licencia_de, modulo_bloqueado
|
||||||
|
|
||||||
|
LEGACY_KEY_PARAM = 'skeen.api.legacy_key'
|
||||||
|
LEGACY_KEY_DEFAULT = 'skeen_api_key_2026'
|
||||||
|
|
||||||
|
# Rutas del frontend cuyo módulo se controla por licencia. Los módulos con
|
||||||
|
# bloque `activo` (compras, membresías, comisiones, recursos, notificaciones,
|
||||||
|
# reservas) ya responden 403 solos al apagarse, así que aquí solo hacen falta
|
||||||
|
# los que no tienen ese bloque.
|
||||||
|
RUTAS_LICENCIA = (
|
||||||
|
('/skeen/frontend/v1/formularios', 'formularios'),
|
||||||
|
('/skeen/frontend/v1/wacrm', 'whatsapp'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _respuesta_licencia():
|
||||||
|
"""Respuesta 403 JSON si la ruta pertenece a un módulo fuera de la licencia.
|
||||||
|
|
||||||
|
Devuelve None si la petición puede continuar. No aplica a OPTIONS (preflight
|
||||||
|
CORS) ni cuando no hay licencia en modo bloqueo."""
|
||||||
|
try:
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return None
|
||||||
|
path = request.httprequest.path or ''
|
||||||
|
modulo = next((m for prefijo, m in RUTAS_LICENCIA if path.startswith(prefijo)), None)
|
||||||
|
if not modulo or not request.env:
|
||||||
|
return None
|
||||||
|
if not modulo_bloqueado(licencia_de(request.env), modulo):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
cuerpo = json.dumps({'status': 'error',
|
||||||
|
'message': 'Módulo no incluido en tu plan'},
|
||||||
|
ensure_ascii=False)
|
||||||
|
return Response(cuerpo, status=403, mimetype='application/json',
|
||||||
|
headers={'Access-Control-Allow-Origin': '*'})
|
||||||
|
|
||||||
|
|
||||||
|
def extraer_clave(req):
|
||||||
|
"""Devuelve la clave de API de la petición (o '')."""
|
||||||
|
try:
|
||||||
|
headers = req.httprequest.headers
|
||||||
|
auth = headers.get('Authorization', '') or ''
|
||||||
|
if auth.lower().startswith('bearer '):
|
||||||
|
return auth[7:].strip()
|
||||||
|
for header in ('X-API-Key', 'X-Api-Key', 'Api-Key'):
|
||||||
|
valor = headers.get(header)
|
||||||
|
if valor:
|
||||||
|
return valor.strip()
|
||||||
|
args = req.httprequest.args
|
||||||
|
for parametro in ('api_key', 'api-key', 'apikey'):
|
||||||
|
if args.get(parametro):
|
||||||
|
return args.get(parametro).strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def clave_permite_scope(req, scope):
|
||||||
|
"""True si la clave de la petición concede el alcance `scope`.
|
||||||
|
|
||||||
|
La clave legacy de Hermes tiene todos los alcances. Una petición sin
|
||||||
|
`request.api_key` (p. ej. dentro de pruebas) se considera legacy.
|
||||||
|
"""
|
||||||
|
key = getattr(req, 'api_key', None)
|
||||||
|
if key is None:
|
||||||
|
return True
|
||||||
|
return key.permite(scope)
|
||||||
|
|
||||||
|
|
||||||
|
def exigir_scope(req, scope):
|
||||||
|
"""Lanza 403 (HTTP 403) si la clave no tiene el alcance indicado."""
|
||||||
|
if not clave_permite_scope(req, scope):
|
||||||
|
raise Forbidden(description='La clave de API no tiene el alcance "%s".' % scope)
|
||||||
|
|
||||||
|
|
||||||
|
class IrHttp(models.AbstractModel):
|
||||||
|
_inherit = 'ir.http'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _dispatch(cls, endpoint):
|
||||||
|
respuesta = _respuesta_licencia()
|
||||||
|
if respuesta is not None:
|
||||||
|
return respuesta
|
||||||
|
return super()._dispatch(endpoint)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _auth_method_api_key(cls):
|
||||||
|
raw = extraer_clave(request)
|
||||||
|
if not raw:
|
||||||
|
raise AccessDenied('Falta la clave de API (Authorization: Bearer ...).')
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
legacy = ICP.get_param(LEGACY_KEY_PARAM) or LEGACY_KEY_DEFAULT
|
||||||
|
if hmac.compare_digest(raw, legacy):
|
||||||
|
request.api_key = None
|
||||||
|
request.api_key_legacy = True
|
||||||
|
request.update_env(user=SUPERUSER_ID)
|
||||||
|
return
|
||||||
|
key = None
|
||||||
|
if 'skeen.api.key' in request.env:
|
||||||
|
key = request.env['skeen.api.key'].sudo()._autenticar(raw)
|
||||||
|
if not key:
|
||||||
|
raise AccessDenied('Clave de API inválida o revocada.')
|
||||||
|
request.api_key = key
|
||||||
|
request.api_key_legacy = False
|
||||||
|
key._registrar_uso()
|
||||||
|
request.update_env(user=SUPERUSER_ID)
|
||||||
125
odoo-addons/skeen_plataforma/models/limites.py
Normal file
125
odoo-addons/skeen_plataforma/models/limites.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Aplicación de los límites de la licencia.
|
||||||
|
|
||||||
|
Solo actúan con `modo='bloqueo'` y un límite > 0. Con `aviso` (default) o sin
|
||||||
|
licencia no bloquean nada. Nunca afectan tareas internas: se pueden saltar con
|
||||||
|
el contexto `skeen_licencia_skip=True` (cron, migraciones, importaciones).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import (
|
||||||
|
licencia_de, limite_licencia, licencia_bloqueo_activo,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MENSAJES = {
|
||||||
|
'usuarios_max': 'usuarios',
|
||||||
|
'sucursales_max': 'sucursales',
|
||||||
|
'citas_mes_max': 'citas este mes',
|
||||||
|
'almacenamiento_mb': 'MB de almacenamiento',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _skip(env):
|
||||||
|
return bool(env.context.get('skeen_licencia_skip')
|
||||||
|
or env.context.get('install_mode'))
|
||||||
|
|
||||||
|
|
||||||
|
def exigir_limite(env, clave, etiqueta, actual, agregando=1):
|
||||||
|
"""Lanza UserError si `actual + agregando` supera el límite de `clave`.
|
||||||
|
|
||||||
|
Con modo=aviso solo deja una advertencia en el log. Devuelve False si no
|
||||||
|
aplica (sin licencia, sin límite o contexto de salto).
|
||||||
|
"""
|
||||||
|
limite = limite_licencia(env, clave)
|
||||||
|
if limite <= 0 or _skip(env):
|
||||||
|
return False
|
||||||
|
licencia = licencia_de(env)
|
||||||
|
if not licencia_bloqueo_activo(licencia):
|
||||||
|
if actual + agregando > limite:
|
||||||
|
_logger.info('SKEEN licencia (aviso): %s excede el límite de %s '
|
||||||
|
'(%s + %s > %s)', etiqueta, limite, actual, agregando, limite)
|
||||||
|
return False
|
||||||
|
if actual + agregando > limite:
|
||||||
|
raise UserError(_(
|
||||||
|
'Tu plan permite hasta %(limite)s %(etiqueta)s y ya tienes '
|
||||||
|
'%(actual)s. Para ampliar el límite, contacta a tu proveedor y '
|
||||||
|
'solicita una licencia con un tope mayor.'
|
||||||
|
) % {'limite': limite, 'etiqueta': etiqueta, 'actual': actual})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _contar_usuarios(env):
|
||||||
|
return env['skeen.frontend.user'].sudo().search_count([('active', '=', True)])
|
||||||
|
|
||||||
|
|
||||||
|
def _contar_sucursales(env):
|
||||||
|
return env['skeen.sucursal'].sudo().search_count([('active', '=', True)])
|
||||||
|
|
||||||
|
|
||||||
|
def _contar_citas_mes(env):
|
||||||
|
hoy = date.today()
|
||||||
|
inicio = hoy.replace(day=1)
|
||||||
|
if hoy.month == 12:
|
||||||
|
fin = hoy.replace(year=hoy.year + 1, month=1, day=1)
|
||||||
|
else:
|
||||||
|
fin = hoy.replace(month=hoy.month + 1, day=1)
|
||||||
|
return env['skeen.cita'].sudo().search_count([
|
||||||
|
('date', '>=', inicio), ('date', '<', fin),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _mb_almacenamiento(env):
|
||||||
|
env.cr.execute('SELECT COALESCE(SUM(file_size), 0) FROM ir_attachment')
|
||||||
|
total = env.cr.fetchone()[0] or 0
|
||||||
|
return total / (1024.0 * 1024.0)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenFrontendUser(models.Model):
|
||||||
|
_inherit = 'skeen.frontend.user'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
exigir_limite(self.env, 'usuarios_max', MENSAJES['usuarios_max'],
|
||||||
|
_contar_usuarios(self.env), len(vals_list))
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenSucursal(models.Model):
|
||||||
|
_inherit = 'skeen.sucursal'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
exigir_limite(self.env, 'sucursales_max', MENSAJES['sucursales_max'],
|
||||||
|
_contar_sucursales(self.env), len(vals_list))
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCita(models.Model):
|
||||||
|
_inherit = 'skeen.cita'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
exigir_limite(self.env, 'citas_mes_max', MENSAJES['citas_mes_max'],
|
||||||
|
_contar_citas_mes(self.env), len(vals_list))
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
|
||||||
|
class IrAttachment(models.Model):
|
||||||
|
_inherit = 'ir.attachment'
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
if limite_licencia(self.env, 'almacenamiento_mb') > 0 and not _skip(self.env):
|
||||||
|
nuevos_mb = sum(max(0, int(v.get('file_size') or 0)) for v in vals_list) \
|
||||||
|
/ (1024.0 * 1024.0)
|
||||||
|
exigir_limite(self.env, 'almacenamiento_mb',
|
||||||
|
MENSAJES['almacenamiento_mb'],
|
||||||
|
_mb_almacenamiento(self.env), nuevos_mb)
|
||||||
|
return super().create(vals_list)
|
||||||
367
odoo-addons/skeen_plataforma/models/webhook.py
Normal file
367
odoo-addons/skeen_plataforma/models/webhook.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Webhooks salientes: outbox, entrega por cron, reintentos y log.
|
||||||
|
|
||||||
|
Sin webhooks activos suscritos a un evento NO se crea ninguna fila en el outbox
|
||||||
|
y el cron no hace nada: cero impacto en una instancia sin configurar. La entrega
|
||||||
|
es asíncrona (cron cada 5 minutos) para no añadir latencia a los flujos.
|
||||||
|
|
||||||
|
Firma: `X-Skeen-Signature: sha256=<HMAC-SHA256 del cuerpo con el secreto>`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Catálogo de eventos emitibles (clave, etiqueta).
|
||||||
|
EVENTOS = [
|
||||||
|
('cita.creada', 'Cita creada'),
|
||||||
|
('cita.confirmada', 'Cita confirmada'),
|
||||||
|
('cita.cancelada', 'Cita cancelada'),
|
||||||
|
('cita.no_show', 'Cita no presentada'),
|
||||||
|
('cita.completada', 'Cita completada'),
|
||||||
|
('venta.pagada', 'Venta pagada'),
|
||||||
|
('venta.cancelada', 'Venta cancelada'),
|
||||||
|
('pago.registrado', 'Pago registrado'),
|
||||||
|
('paciente.creado', 'Paciente creado'),
|
||||||
|
('membresia.renovada', 'Membresía renovada'),
|
||||||
|
('formulario.llenado', 'Formulario llenado'),
|
||||||
|
('compra.recibida', 'Compra recibida'),
|
||||||
|
]
|
||||||
|
EVENTO_KEYS = tuple(k for k, _l in EVENTOS)
|
||||||
|
EVENTO_LABEL = dict(EVENTOS)
|
||||||
|
|
||||||
|
ESTADOS = [
|
||||||
|
('pendiente', 'Pendiente'),
|
||||||
|
('enviado', 'Enviado'),
|
||||||
|
('fallido', 'Fallido'),
|
||||||
|
('descartado', 'Descartado'),
|
||||||
|
]
|
||||||
|
|
||||||
|
MAX_INTENTOS = 5
|
||||||
|
BACKOFF_MIN = (1, 5, 30, 120, 360) # minutos entre reintentos
|
||||||
|
TIMEOUT = (5, 15) # (conexión, lectura) segundos
|
||||||
|
FALLOS_MAX_DEFAULT = 20
|
||||||
|
PROCESAR_POR_DEFECTO = 100
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenWebhook(models.Model):
|
||||||
|
_name = 'skeen.webhook'
|
||||||
|
_description = 'Webhook saliente'
|
||||||
|
_order = 'name, id'
|
||||||
|
|
||||||
|
name = fields.Char(string='Nombre', required=True)
|
||||||
|
url = fields.Char(string='URL', required=True)
|
||||||
|
eventos = fields.Char(string='Eventos', default='',
|
||||||
|
help='Claves separadas por coma. Vacío = todos.')
|
||||||
|
activo = fields.Boolean(string='Activo', default=True)
|
||||||
|
secreto = fields.Char(string='Secreto', help='Con este secreto se firma cada entrega.')
|
||||||
|
headers = fields.Text(string='Cabeceras extra (JSON)')
|
||||||
|
notas = fields.Text(string='Notas')
|
||||||
|
creado_por_login = fields.Char(string='Creado por')
|
||||||
|
ultima_entrega_at = fields.Datetime(string='Última entrega')
|
||||||
|
fallos_consecutivos = fields.Integer(string='Fallos consecutivos', default=0)
|
||||||
|
|
||||||
|
def eventos_list(self):
|
||||||
|
return [e.strip() for e in (self.eventos or '').split(',') if e.strip()]
|
||||||
|
|
||||||
|
def suscrito(self, evento):
|
||||||
|
"""True si el webhook recibe ese evento (lista vacía = todos)."""
|
||||||
|
lista = self.eventos_list()
|
||||||
|
return (not lista) or (evento in lista)
|
||||||
|
|
||||||
|
def headers_dict(self):
|
||||||
|
try:
|
||||||
|
val = json.loads(self.headers or '{}')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
return val if isinstance(val, dict) else {}
|
||||||
|
|
||||||
|
def to_public_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'url': self.url,
|
||||||
|
'eventos': self.eventos_list(),
|
||||||
|
'eventos_label': 'Todos' if not self.eventos_list() else
|
||||||
|
', '.join(EVENTO_LABEL.get(e, e) for e in self.eventos_list()),
|
||||||
|
'activo': self.activo,
|
||||||
|
'tiene_secreto': bool(self.secreto),
|
||||||
|
'headers': self.headers_dict(),
|
||||||
|
'notas': self.notas or '',
|
||||||
|
'creado_por_login': self.creado_por_login or '',
|
||||||
|
'ultima_entrega_at': self.ultima_entrega_at.strftime('%Y-%m-%d %H:%M')
|
||||||
|
if self.ultima_entrega_at else '',
|
||||||
|
'fallos_consecutivos': self.fallos_consecutivos,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenWebhookEvento(models.Model):
|
||||||
|
_name = 'skeen.webhook.evento'
|
||||||
|
_description = 'Evento de webhook (outbox)'
|
||||||
|
_order = 'create_date desc, id desc'
|
||||||
|
|
||||||
|
webhook_id = fields.Many2one('skeen.webhook', string='Webhook', required=True,
|
||||||
|
ondelete='cascade', index=True)
|
||||||
|
evento = fields.Char(string='Evento', required=True, index=True)
|
||||||
|
payload = fields.Text(string='Payload (JSON)')
|
||||||
|
estado = fields.Selection(ESTADOS, string='Estado', default='pendiente',
|
||||||
|
required=True, index=True)
|
||||||
|
intentos = fields.Integer(string='Intentos', default=0)
|
||||||
|
proximo_intento = fields.Datetime(string='Próximo intento', index=True)
|
||||||
|
respuesta_codigo = fields.Integer(string='Código de respuesta')
|
||||||
|
respuesta_texto = fields.Text(string='Respuesta')
|
||||||
|
enviado_at = fields.Datetime(string='Enviado el')
|
||||||
|
clave_unica = fields.Char(string='Clave única', index=True)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('clave_unica_uniq', 'unique(clave_unica)',
|
||||||
|
'Ya existe un evento con esa clave (evita duplicados).'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_public_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'webhook_id': self.webhook_id.id,
|
||||||
|
'webhook': self.webhook_id.name,
|
||||||
|
'evento': self.evento,
|
||||||
|
'evento_label': EVENTO_LABEL.get(self.evento, self.evento),
|
||||||
|
'estado': self.estado,
|
||||||
|
'estado_label': dict(ESTADOS).get(self.estado, self.estado),
|
||||||
|
'intentos': self.intentos,
|
||||||
|
'proximo_intento': self.proximo_intento.strftime('%Y-%m-%d %H:%M')
|
||||||
|
if self.proximo_intento else '',
|
||||||
|
'respuesta_codigo': self.respuesta_codigo or 0,
|
||||||
|
'respuesta_texto': (self.respuesta_texto or '')[:500],
|
||||||
|
'enviado_at': self.enviado_at.strftime('%Y-%m-%d %H:%M') if self.enviado_at else '',
|
||||||
|
'creado_at': self.create_date.strftime('%Y-%m-%d %H:%M') if self.create_date else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenWebhookService(models.AbstractModel):
|
||||||
|
_name = 'skeen.webhook.service'
|
||||||
|
_description = 'Servicio de webhooks salientes'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Emisión al outbox
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def hay_activos(self):
|
||||||
|
return bool(self.env['skeen.webhook'].sudo().search_count([('activo', '=', True)]))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _suscriptores(self, evento):
|
||||||
|
activos = self.env['skeen.webhook'].sudo().search([('activo', '=', True)])
|
||||||
|
return activos.filtered(lambda w: w.suscrito(evento))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def emitir(self, evento, payload, clave_unica=None, webhook_ids=None):
|
||||||
|
"""Encola el evento para los webhooks activos suscritos.
|
||||||
|
|
||||||
|
Sin webhooks activos suscritos no crea nada (cero impacto). Devuelve el
|
||||||
|
recordset de eventos creados.
|
||||||
|
"""
|
||||||
|
if evento not in EVENTO_KEYS and evento != 'prueba':
|
||||||
|
return self.env['skeen.webhook.evento'].browse()
|
||||||
|
if webhook_ids is None:
|
||||||
|
suscriptores = self._suscriptores(evento)
|
||||||
|
else:
|
||||||
|
suscriptores = self.env['skeen.webhook'].sudo().browse(webhook_ids)
|
||||||
|
suscriptores = suscriptores.filtered(lambda w: w.suscrito(evento))
|
||||||
|
if not suscriptores:
|
||||||
|
return self.env['skeen.webhook.evento'].browse()
|
||||||
|
Evento = self.env['skeen.webhook.evento'].sudo()
|
||||||
|
texto = json.dumps(payload or {}, ensure_ascii=False, default=str)
|
||||||
|
creados = Evento
|
||||||
|
ahora = fields.Datetime.now()
|
||||||
|
for webhook in suscriptores:
|
||||||
|
clave = clave_unica
|
||||||
|
if clave:
|
||||||
|
clave = '%s:w%s' % (clave, webhook.id)
|
||||||
|
if Evento.search_count([('clave_unica', '=', clave)]):
|
||||||
|
continue
|
||||||
|
creados |= Evento.create({
|
||||||
|
'webhook_id': webhook.id,
|
||||||
|
'evento': evento,
|
||||||
|
'payload': texto,
|
||||||
|
'estado': 'pendiente',
|
||||||
|
'proximo_intento': ahora,
|
||||||
|
'clave_unica': clave or None,
|
||||||
|
})
|
||||||
|
return creados
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Entrega
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _cron_tick(self):
|
||||||
|
return self.procesar()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def procesar(self, limite=PROCESAR_POR_DEFECTO):
|
||||||
|
"""Envía los eventos pendientes ya vencidos. Nunca detiene el cron."""
|
||||||
|
ahora = fields.Datetime.now()
|
||||||
|
pendientes = self.env['skeen.webhook.evento'].sudo().search([
|
||||||
|
('estado', '=', 'pendiente'),
|
||||||
|
('proximo_intento', '<=', ahora),
|
||||||
|
], order='proximo_intento asc, id asc', limit=limite)
|
||||||
|
resultado = {'revisados': len(pendientes), 'enviados': 0, 'fallidos': 0}
|
||||||
|
for evento in pendientes:
|
||||||
|
try:
|
||||||
|
estado = self._entregar(evento)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('Webhook evento %s: error inesperado', evento.id)
|
||||||
|
self._fallar(evento, 'Error inesperado al entregar')
|
||||||
|
estado = 'fallido'
|
||||||
|
if estado == 'enviado':
|
||||||
|
resultado['enviados'] += 1
|
||||||
|
elif estado == 'fallido':
|
||||||
|
resultado['fallidos'] += 1
|
||||||
|
if resultado['revisados']:
|
||||||
|
_logger.info('SKEEN webhooks: %s', resultado)
|
||||||
|
return resultado
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def firmar(self, secreto, cuerpo_bytes):
|
||||||
|
"""Firma `sha256=<hex>` del cuerpo con el secreto."""
|
||||||
|
if not secreto:
|
||||||
|
return ''
|
||||||
|
digest = hmac.new(secreto.encode('utf-8'), cuerpo_bytes, hashlib.sha256)
|
||||||
|
return 'sha256=' + digest.hexdigest()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _entregar(self, evento):
|
||||||
|
webhook = evento.webhook_id
|
||||||
|
if not webhook.activo:
|
||||||
|
evento.write({'estado': 'descartado',
|
||||||
|
'respuesta_texto': 'El webhook está desactivado.'})
|
||||||
|
return 'descartado'
|
||||||
|
cuerpo = (evento.payload or '{}').encode('utf-8')
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Skeen-Event': evento.evento,
|
||||||
|
'X-Skeen-Delivery': uuid.uuid4().hex,
|
||||||
|
}
|
||||||
|
firma = self.firmar(webhook.secreto or '', cuerpo)
|
||||||
|
if firma:
|
||||||
|
headers['X-Skeen-Signature'] = firma
|
||||||
|
for k, v in (webhook.headers_dict() or {}).items():
|
||||||
|
headers[str(k)] = str(v)
|
||||||
|
try:
|
||||||
|
resp = requests.post(webhook.url, data=cuerpo, headers=headers,
|
||||||
|
timeout=TIMEOUT)
|
||||||
|
codigo = resp.status_code
|
||||||
|
texto = (resp.text or '')[:500]
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
self._fallar(evento, 'No se pudo conectar: %s' % exc)
|
||||||
|
return 'fallido'
|
||||||
|
if 200 <= codigo < 300:
|
||||||
|
evento.write({
|
||||||
|
'estado': 'enviado', 'intentos': evento.intentos + 1,
|
||||||
|
'enviado_at': fields.Datetime.now(), 'respuesta_codigo': codigo,
|
||||||
|
'respuesta_texto': texto,
|
||||||
|
})
|
||||||
|
webhook.write({'ultima_entrega_at': fields.Datetime.now(),
|
||||||
|
'fallos_consecutivos': 0})
|
||||||
|
return 'enviado'
|
||||||
|
self._fallar(evento, texto or ('HTTP %s' % codigo), codigo=codigo)
|
||||||
|
return 'fallido'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _fallar(self, evento, texto, codigo=0):
|
||||||
|
intentos = evento.intentos + 1
|
||||||
|
vals = {'intentos': intentos, 'respuesta_codigo': codigo,
|
||||||
|
'respuesta_texto': (texto or '')[:500]}
|
||||||
|
if intentos >= MAX_INTENTOS:
|
||||||
|
vals['estado'] = 'fallido'
|
||||||
|
else:
|
||||||
|
espera = BACKOFF_MIN[min(intentos - 1, len(BACKOFF_MIN) - 1)]
|
||||||
|
vals['proximo_intento'] = fields.Datetime.now() + timedelta(minutes=espera)
|
||||||
|
evento.write(vals)
|
||||||
|
webhook = evento.webhook_id
|
||||||
|
fallos = webhook.fallos_consecutivos + 1
|
||||||
|
umbral = self._umbral_fallos()
|
||||||
|
vals_wh = {'fallos_consecutivos': fallos}
|
||||||
|
if umbral and fallos >= umbral:
|
||||||
|
vals_wh['activo'] = False
|
||||||
|
_logger.warning('SKEEN webhook %s desactivado tras %s fallos consecutivos',
|
||||||
|
webhook.id, fallos)
|
||||||
|
webhook.write(vals_wh)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _umbral_fallos(self):
|
||||||
|
raw = self.env['ir.config_parameter'].sudo().get_param('skeen.webhooks.fallos_max')
|
||||||
|
try:
|
||||||
|
return int(raw) if raw else FALLOS_MAX_DEFAULT
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return FALLOS_MAX_DEFAULT
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Acciones de UI
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def reintentar(self, evento):
|
||||||
|
"""Devuelve a la cola un evento fallido y lo intenta de inmediato."""
|
||||||
|
if evento.estado in ('enviado', 'descartado'):
|
||||||
|
return evento.estado
|
||||||
|
evento.write({'estado': 'pendiente', 'proximo_intento': fields.Datetime.now()})
|
||||||
|
return self._entregar(evento)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def probar(self, webhook, login=''):
|
||||||
|
"""Envía un evento de prueba (ignora la suscripción) y devuelve el log."""
|
||||||
|
ejemplo = {
|
||||||
|
'evento': 'prueba',
|
||||||
|
'mensaje': 'Prueba de webhook SKEEN',
|
||||||
|
'webhook': webhook.name,
|
||||||
|
'generado_por': login or 'sistema',
|
||||||
|
}
|
||||||
|
evento = self.env['skeen.webhook.evento'].sudo().create({
|
||||||
|
'webhook_id': webhook.id,
|
||||||
|
'evento': 'prueba',
|
||||||
|
'payload': json.dumps(ejemplo, ensure_ascii=False),
|
||||||
|
'estado': 'pendiente',
|
||||||
|
'proximo_intento': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
estado = self._entregar(evento)
|
||||||
|
ok = estado == 'enviado'
|
||||||
|
mensaje = ('Entrega de prueba correcta.' if ok
|
||||||
|
else 'La entrega falló (código %s): %s'
|
||||||
|
% (evento.respuesta_codigo or 0, evento.respuesta_texto or ''))
|
||||||
|
return ok, mensaje, evento
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def payload_ejemplo(self, evento):
|
||||||
|
"""Payload de ejemplo de un evento, para el visor de la UI."""
|
||||||
|
payloads = {
|
||||||
|
'cita.creada': {'id': 123, 'reference': 'CITA-0123', 'state': 'pending',
|
||||||
|
'date': '2026-10-20', 'time': '10:30',
|
||||||
|
'patient_id': 55, 'patient': 'María López',
|
||||||
|
'service_id': 7, 'servicio': 'Limpieza facial'},
|
||||||
|
'cita.confirmada': {'id': 123, 'reference': 'CITA-0123', 'state': 'confirmed'},
|
||||||
|
'cita.cancelada': {'id': 123, 'reference': 'CITA-0123', 'state': 'cancelled'},
|
||||||
|
'cita.no_show': {'id': 123, 'reference': 'CITA-0123', 'state': 'no_show'},
|
||||||
|
'cita.completada': {'id': 123, 'reference': 'CITA-0123', 'state': 'done'},
|
||||||
|
'venta.pagada': {'id': 900, 'reference': 'V-00900', 'state': 'paid',
|
||||||
|
'total': 1500.0, 'patient_id': 55},
|
||||||
|
'venta.cancelada': {'id': 900, 'reference': 'V-00900', 'state': 'cancelled'},
|
||||||
|
'pago.registrado': {'id': 700, 'reference': 'P-00700', 'amount': 500.0,
|
||||||
|
'payment_method': 'cash', 'patient_id': 55},
|
||||||
|
'paciente.creado': {'id': 55, 'name': 'María López', 'phone': '+526641234567',
|
||||||
|
'is_patient': True},
|
||||||
|
'membresia.renovada': {'id': 30, 'plan': 'Mensual', 'estado': 'activa',
|
||||||
|
'periodo_actual_inicio': '2026-11-01',
|
||||||
|
'periodo_actual_fin': '2026-11-30'},
|
||||||
|
'formulario.llenado': {'id': 12, 'plantilla': 'Consentimiento',
|
||||||
|
'estado': 'llenado', 'patient_id': 55},
|
||||||
|
'compra.recibida': {'id': 44, 'referencia': 'OC-0044', 'estado': 'recibida'},
|
||||||
|
}
|
||||||
|
return payloads.get(evento, {'evento': evento})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_skeen_api_key_user,skeen.api.key.user,model_skeen_api_key,base.group_user,1,1,1,1
|
||||||
|
access_skeen_webhook_user,skeen.webhook.user,model_skeen_webhook,base.group_user,1,1,1,1
|
||||||
|
access_skeen_webhook_evento_user,skeen.webhook.evento.user,model_skeen_webhook_evento,base.group_user,1,1,1,1
|
||||||
|
@@ -1,28 +1,94 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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 import http
|
||||||
from odoo.http import request
|
from odoo.http import request, Response
|
||||||
import json
|
|
||||||
|
|
||||||
|
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):
|
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)
|
@http.route('/skeen/api/v1/available_slots', type='http', auth='api_key',
|
||||||
def get_available_slots(self, service_id, date_from, date_to, **kw):
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
"""Consultar slots disponibles para un servicio en un rango de fechas"""
|
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:
|
try:
|
||||||
servicio = request.env['skeen.servicio'].browse(int(service_id))
|
servicio = request.env['skeen.servicio'].browse(int(service_id))
|
||||||
if not servicio.exists():
|
if not servicio.exists():
|
||||||
return {'status': 'error', 'message': 'Servicio no encontrado'}
|
return _json({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
|
||||||
|
|
||||||
slots = []
|
slots = []
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
current_date = datetime.strptime(date_from, '%Y-%m-%d').date()
|
current_date = datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||||
end_date = datetime.strptime(date_to, '%Y-%m-%d').date()
|
end_date = datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||||
|
|
||||||
while current_date <= end_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:
|
for slot in day_slots:
|
||||||
slots.append({
|
slots.append({
|
||||||
'date': current_date.strftime('%Y-%m-%d'),
|
'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']}",
|
'datetime': f"{current_date.strftime('%Y-%m-%d')} {slot['time_str']}",
|
||||||
})
|
})
|
||||||
current_date += timedelta(days=1)
|
current_date += timedelta(days=1)
|
||||||
|
return _json({'status': 'success', 'slots': slots})
|
||||||
return {'status': 'success', 'slots': slots}
|
|
||||||
except Exception as e:
|
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)
|
@http.route('/skeen/api/v1/create_appointment', type='http', auth='api_key',
|
||||||
def create_appointment(self, phone, name, service_id, date, time, doctor_id=None, duration=None, **kw):
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
"""Crear una nueva cita (flujo Hermes/WACRM).
|
def create_appointment(self, **kw):
|
||||||
|
"""Crear una nueva cita (flujo Hermes/WACRM)."""
|
||||||
`doctor_id` y `duration` son opcionales. Si se indica médico, se validan
|
denied = _scope_ok('citas.write')
|
||||||
su horario semanal y sus bloqueos de agenda; sin médico, la cita se
|
if denied:
|
||||||
rechaza solo si la clínica completa está bloqueada a esa hora.
|
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:
|
try:
|
||||||
# Buscar o crear paciente
|
|
||||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||||
if not partner:
|
if not partner:
|
||||||
partner = request.env['res.partner'].create({
|
partner = request.env['res.partner'].create({
|
||||||
@@ -53,12 +127,8 @@ class SkeenWhatsAppController(http.Controller):
|
|||||||
'is_patient': True,
|
'is_patient': True,
|
||||||
'source': 'whatsapp',
|
'source': 'whatsapp',
|
||||||
})
|
})
|
||||||
|
time_parts = str(time).split(':')
|
||||||
# Convertir time a float
|
|
||||||
time_parts = time.split(':')
|
|
||||||
time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0
|
time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0
|
||||||
|
|
||||||
# Crear cita
|
|
||||||
vals = {
|
vals = {
|
||||||
'partner_id': partner.id,
|
'partner_id': partner.id,
|
||||||
'servicio_id': int(service_id),
|
'servicio_id': int(service_id),
|
||||||
@@ -71,37 +141,38 @@ class SkeenWhatsAppController(http.Controller):
|
|||||||
if duration:
|
if duration:
|
||||||
vals['duration'] = int(duration)
|
vals['duration'] = int(duration)
|
||||||
cita = request.env['skeen.cita'].create(vals)
|
cita = request.env['skeen.cita'].create(vals)
|
||||||
|
return _json({
|
||||||
return {
|
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'booking_id': cita.id,
|
'booking_id': cita.id,
|
||||||
'reference': cita.name,
|
'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:
|
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)
|
@http.route('/skeen/api/v1/patient_balance', type='http', auth='api_key',
|
||||||
def get_patient_balance(self, phone, **kw):
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
"""Obtener saldo pendiente y monedero del paciente"""
|
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:
|
try:
|
||||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||||
if not partner:
|
if not partner:
|
||||||
return {'status': 'error', 'message': 'Paciente no encontrado'}
|
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||||
|
|
||||||
# Citas pendientes de pago
|
|
||||||
pending_citas = request.env['skeen.cita'].search([
|
pending_citas = request.env['skeen.cita'].search([
|
||||||
('partner_id', '=', partner.id),
|
('partner_id', '=', partner.id),
|
||||||
('payment_state', '!=', 'paid'),
|
('payment_state', '!=', 'paid'),
|
||||||
('state', 'in', ('confirmed', 'arrived', 'in_progress', 'done')),
|
('state', 'in', ('confirmed', 'arrived', 'in_progress', 'done')),
|
||||||
])
|
])
|
||||||
|
|
||||||
pending_balance = sum(c.servicio_price for c in pending_citas)
|
pending_balance = sum(c.servicio_price for c in pending_citas)
|
||||||
|
monedero = request.env['skeen.monedero'].search(
|
||||||
# Monedero
|
[('partner_id', '=', partner.id)], limit=1)
|
||||||
monedero = request.env['skeen.monedero'].search([('partner_id', '=', partner.id)], limit=1)
|
return _json({
|
||||||
|
|
||||||
return {
|
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'pending_balance': pending_balance,
|
'pending_balance': pending_balance,
|
||||||
'pending_invoices': [{
|
'pending_invoices': [{
|
||||||
@@ -113,35 +184,51 @@ class SkeenWhatsAppController(http.Controller):
|
|||||||
} for c in pending_citas],
|
} for c in pending_citas],
|
||||||
'wallet_points': monedero.points if monedero else 0,
|
'wallet_points': monedero.points if monedero else 0,
|
||||||
'wallet_mxn': monedero.equivalent_mxn if monedero else 0,
|
'wallet_mxn': monedero.equivalent_mxn if monedero else 0,
|
||||||
}
|
})
|
||||||
except Exception as e:
|
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)
|
@http.route('/skeen/api/v1/wallet_redeem', type='http', auth='api_key',
|
||||||
def redeem_wallet(self, phone, points, appointment_id=None, **kw):
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
"""Redimir puntos del monedero"""
|
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:
|
try:
|
||||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||||
if not partner:
|
if not partner:
|
||||||
return {'status': 'error', 'message': 'Paciente no encontrado'}
|
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||||
|
monedero = request.env['skeen.monedero'].search(
|
||||||
monedero = request.env['skeen.monedero'].search([('partner_id', '=', partner.id)], limit=1)
|
[('partner_id', '=', partner.id)], limit=1)
|
||||||
if not monedero:
|
if not monedero:
|
||||||
return {'status': 'error', 'message': 'Monedero no encontrado'}
|
return _json({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
|
||||||
|
|
||||||
monedero.redeem_points(int(points))
|
monedero.redeem_points(int(points))
|
||||||
|
return _json({
|
||||||
return {
|
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'remaining_points': monedero.points,
|
'remaining_points': monedero.points,
|
||||||
'remaining_mxn': monedero.equivalent_mxn,
|
'remaining_mxn': monedero.equivalent_mxn,
|
||||||
}
|
})
|
||||||
except Exception as e:
|
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)
|
@http.route('/skeen/api/v1/cancel_appointment', type='http', auth='api_key',
|
||||||
def cancel_appointment(self, phone, booking_id=None, **kw):
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
"""Cancelar una cita"""
|
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:
|
try:
|
||||||
domain = [
|
domain = [
|
||||||
('partner_id.phone', '=', phone),
|
('partner_id.phone', '=', phone),
|
||||||
@@ -149,26 +236,24 @@ class SkeenWhatsAppController(http.Controller):
|
|||||||
]
|
]
|
||||||
if booking_id:
|
if booking_id:
|
||||||
domain.append(('id', '=', int(booking_id)))
|
domain.append(('id', '=', int(booking_id)))
|
||||||
|
|
||||||
cita = request.env['skeen.cita'].search(domain, order='date asc', limit=1)
|
cita = request.env['skeen.cita'].search(domain, order='date asc', limit=1)
|
||||||
if not cita:
|
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'})
|
cita.write({'state': 'cancelled'})
|
||||||
|
return _json({'status': 'success', 'cancelled_booking': cita.name})
|
||||||
return {
|
|
||||||
'status': 'success',
|
|
||||||
'cancelled_booking': cita.name,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
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):
|
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:
|
try:
|
||||||
services = request.env['skeen.servicio'].search([('active', '=', True)])
|
services = request.env['skeen.servicio'].search([('active', '=', True)])
|
||||||
return {
|
return _json({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'services': [{
|
'services': [{
|
||||||
'id': s.id,
|
'id': s.id,
|
||||||
@@ -182,11 +267,12 @@ class SkeenWhatsAppController(http.Controller):
|
|||||||
'duration_min': s.duration_min,
|
'duration_min': s.duration_min,
|
||||||
'description': s.description,
|
'description': s.description,
|
||||||
} for s in services],
|
} for s in services],
|
||||||
}
|
})
|
||||||
except Exception as e:
|
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):
|
def health_check(self, **kw):
|
||||||
"""Health check para monitoreo"""
|
"""Health check para monitoreo (sin autenticación)."""
|
||||||
return {'status': 'ok', 'service': 'skeen-odoo-api', 'version': '1.0.0'}
|
return _json({'status': 'ok', 'service': 'skeen-odoo-api', 'version': '2.0.0'})
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ PERMISSION_GROUPS = [
|
|||||||
('config.editar', 'Editar la configuración de la clínica'),
|
('config.editar', 'Editar la configuración de la clínica'),
|
||||||
('config.auditoria', 'Ver la auditoría'),
|
('config.auditoria', 'Ver la auditoría'),
|
||||||
]),
|
]),
|
||||||
|
('plataforma', 'Licencia, API y Webhooks', [
|
||||||
|
('api.claves', 'Administrar claves de API'),
|
||||||
|
('webhooks.editar', 'Administrar webhooks'),
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
|
|
||||||
PERM_KEYS = tuple(
|
PERM_KEYS = tuple(
|
||||||
@@ -274,6 +278,9 @@ DEFAULT_PERM_ROLES = {
|
|||||||
'config.roles': AG,
|
'config.roles': AG,
|
||||||
'config.editar': AG,
|
'config.editar': AG,
|
||||||
'config.auditoria': AD,
|
'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
|
# Los roles nuevos (creados desde la UI) arrancan como 'lectura': el admin
|
||||||
|
|||||||
Reference in New Issue
Block a user