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

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

View File

@@ -0,0 +1,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
View 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
View 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".

View 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 &lt;clave&gt;</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;

View File

@@ -49,7 +49,7 @@ import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
import { odooApi, licenciaModuloIncluido } from '../services/odoo';
interface LayoutProps {
children: ReactNode;
@@ -122,7 +122,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term]
@@ -144,8 +144,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
: 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);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();

View 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;

View File

@@ -51,7 +51,7 @@ import { useGlosario, traducirMenu } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
import { odooApi, licenciaModuloIncluido } from '../services/odoo';
interface LayoutProps {
children: ReactNode;
@@ -135,7 +135,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
const menuGroupsT = useMemo(() => {
let base = menuGroups;
if (membresiasCfg.activo) {
@@ -153,8 +153,15 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
? { ...g, items: [...g.items, COMISIONES_ITEM] }
: 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);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas

View 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=&lt;HMAC-SHA256 del cuerpo con el secreto&gt;</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;

View File

@@ -1,5 +1,5 @@
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).
// 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 || {}) },
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
licencia: {
...LICENCIA_DEFAULT,
...(settings?.licencia || {}),
limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) },
},
});
let actual: ClinicSettings = normalizar(null);

View File

@@ -24,6 +24,7 @@ export const PERM_KEYS: string[] = [
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
'api.claves', 'webhooks.editar',
];
const W = ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
@@ -84,6 +85,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'proveedores.ver': ALL, 'proveedores.editar': AG,
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
'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',

View File

@@ -21,6 +21,9 @@ import FormulariosPanel from '../components/FormulariosPanel';
import MembresiasPanel from '../components/MembresiasPanel';
import ComprasPanel from '../components/ComprasPanel';
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 { 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';
@@ -44,11 +47,14 @@ const TABS = [
{ key: 'recetas', label: 'Recetas' },
{ key: 'catalogos', label: 'Catálogos' },
{ key: 'tickets', label: 'Tickets' },
{ key: 'licencia', label: 'Licencia' },
{ key: 'api', label: 'API' },
{ key: 'webhooks', label: 'Webhooks' },
{ key: 'auditoria', label: 'Auditoría' },
] as const;
// 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 = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -76,6 +82,9 @@ const Configuracion: FC = () => {
compras: 'compras.editar',
comisiones: 'comisiones.editar',
recursos: 'recursos.editar',
licencia: 'config.editar',
api: 'api.claves',
webhooks: 'webhooks.editar',
};
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
@@ -445,6 +454,12 @@ const Configuracion: FC = () => {
<FormulariosPanel />
</Card.Body>
</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">
<Card>

View File

@@ -1076,6 +1076,138 @@ export interface ClinicSettings {
recursos: RecursosSettings;
compras: ComprasSettings;
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) ----
@@ -2082,6 +2214,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
recursos: RECURSOS_DEFAULT,
compras: COMPRAS_DEFAULT,
comisiones: COMISIONES_DEFAULT,
licencia: LICENCIA_DEFAULT,
};
export interface FrontendUser {
@@ -2776,6 +2909,79 @@ export const odooApi = {
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 ----
// Con `compras.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta).

View 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())

View File

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

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
def post_init_hook(env):
"""Siembra la clave legacy de Hermes si no existe.
En ambas instancias se siembra con el valor histórico
`skeen_api_key_2026` para no romper a Hermes al desplegar. Si la clínica ya
la cambió (o la borró), no se toca.
"""
ICP = env['ir.config_parameter'].sudo()
if not ICP.get_param('skeen.api.legacy_key'):
ICP.set_param('skeen.api.legacy_key', 'skeen_api_key_2026')

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Plataforma (licencias, API keys y webhooks)',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Licenciamiento por plan, claves de API gestionables y webhooks salientes',
'description': """
Piezas de producto comercial para SKEEN:
* Licenciamiento por plan: el bloque `licencia` de clinic-settings decide
qué módulos incluye cada cliente y sus límites de uso. Sin licencia
aplicada (default) el sistema se comporta EXACTAMENTE como hoy.
* API de terceros gestionable: clave legacy de Hermes intacta + claves
propias con alcances (scopes), vencimiento y revocación.
* Webhooks salientes: outbox + cron + reintentos con backoff + log de
entregas. Con cero webhooks activos no se emite ni se envía nada.
Todo está apagado por defecto: sin licencia, sin claves nuevas y sin
webhooks configurados nada cambia en la instancia.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': [
'base', 'skeen_citas', 'skeen_pacientes', 'skeen_visitas',
'skeen_ventas', 'skeen_pagos', 'skeen_whatsapp', 'skeen_inventario',
'skeen_membresias', 'skeen_compras', 'skeen_comisiones',
'skeen_formularios',
],
'data': [
'security/ir.model.access.csv',
'data/webhooks_cron.xml',
],
'post_init_hook': 'post_init_hook',
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import licencia
from . import api_keys
from . import webhooks

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""CRUD de claves de API de terceros (Configuración -> API)."""
import json
from odoo import http
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, require_perm
from odoo.addons.skeen_plataforma.models.api_key import SCOPES
BASE = '/skeen/frontend/v1'
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _login():
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
class SkeenApiKeysController(http.Controller):
@http.route(BASE + '/api_keys', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('api.claves')
def list_api_keys(self, **kw):
try:
keys = request.env['skeen.api.key'].sudo().search([])
return _json({
'status': 'success',
'api_keys': [k.to_public_dict() for k in keys],
'scopes': [{'clave': c, 'label': l} for c, l in SCOPES],
})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/api_keys', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('api.claves')
def create_api_key(self, **kw):
try:
data = _body()
nombre = (data.get('name') or '').strip()
if not nombre:
return _json({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
key, raw = request.env['skeen.api.key'].sudo().generar({
'name': nombre,
'scopes': data.get('scopes') or [],
'vence': data.get('vence') or False,
'notas': data.get('notas') or '',
}, login=_login())
return _json({
'status': 'success',
'clave': raw, # se muestra UNA sola vez
'api_key': key.to_public_dict(),
'message': 'Guarda esta clave: no se volverá a mostrar.',
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/api_keys/<int:key_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('api.claves')
def update_api_key(self, key_id, **kw):
try:
key = request.env['skeen.api.key'].sudo().browse(key_id)
if not key.exists():
return _json({'status': 'error', 'message': 'Clave no encontrada'}, 404)
data = _body()
vals = {}
if 'name' in data and (data.get('name') or '').strip():
vals['name'] = data['name'].strip()
if 'scopes' in data:
vals['scopes'] = json.dumps(key._normalizar_scopes(data.get('scopes') or []))
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'vence' in data:
vals['vence'] = data.get('vence') or False
if 'notas' in data:
vals['notas'] = data.get('notas') or ''
if vals:
key.write(vals)
return _json({'status': 'success', 'api_key': key.to_public_dict()})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/api_keys/<int:key_id>/revocar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('api.claves')
def revocar_api_key(self, key_id, **kw):
try:
key = request.env['skeen.api.key'].sudo().browse(key_id)
if not key.exists():
return _json({'status': 'error', 'message': 'Clave no encontrada'}, 404)
key.action_revocar(login=_login())
return _json({'status': 'success', 'message': 'Clave revocada.',
'api_key': key.to_public_dict()})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,219 @@
# -*- coding: utf-8 -*-
"""API de licencia por plan (Configuración -> Licencia).
Solo administración del sistema (PUT) y administración/gerencia (GET/validar).
Nunca bloquea el login, la agenda ni la configuración.
"""
import json
from odoo import fields, http
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, current_user
from odoo.addons.skeen_citas.clinic_settings import (
PLANES, MODULOS_BASE, MODULOS_GATEABLES, MODULO_LABEL, LIMITE_KEYS,
get_settings, set_settings, licencia_de, modulos_licencia,
licencia_bloqueo_activo, verificar_clave_licencia, licencia_desde_payload,
dias_para_vencer, LICENCIA_DEFAULT,
)
BASE = '/skeen/frontend/v1'
LIMITE_LABEL = {
'usuarios_max': 'Usuarios',
'sucursales_max': 'Sucursales',
'citas_mes_max': 'Citas del mes',
'almacenamiento_mb': 'Almacenamiento (MB)',
}
AVISO_DIAS = 15
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _puede_ver():
u = current_user(request.env)
return bool(u and u.role in ('admin', 'gerencia'))
def _puede_editar():
u = current_user(request.env)
return bool(u and u.role == 'admin')
def _uso(env):
"""Uso actual de cada límite frente a su tope."""
from datetime import date
hoy = date.today()
inicio = hoy.replace(day=1)
fin = hoy.replace(year=hoy.year + 1, month=1, day=1) if hoy.month == 12 \
else hoy.replace(month=hoy.month + 1, day=1)
env.cr.execute('SELECT COALESCE(SUM(file_size), 0) FROM ir_attachment')
bytes_almacen = env.cr.fetchone()[0] or 0
return {
'usuarios_max': env['skeen.frontend.user'].sudo().search_count([('active', '=', True)]),
'sucursales_max': env['skeen.sucursal'].sudo().search_count([('active', '=', True)]),
'citas_mes_max': env['skeen.cita'].sudo().search_count(
[('date', '>=', inicio), ('date', '<', fin)]),
'almacenamiento_mb': round(bytes_almacen / (1024.0 * 1024.0), 2),
}
def _estado(env):
settings = get_settings(env)
licencia = settings.get('licencia') or dict(LICENCIA_DEFAULT)
limites = licencia.get('limites') or {}
uso = _uso(env)
filas = []
for clave in LIMITE_KEYS:
tope = int(limites.get(clave) or 0)
actual = uso.get(clave) or 0
if tope <= 0:
pct = 0
estado = 'sin_limite'
else:
pct = round(min(100.0, (actual / float(tope)) * 100.0), 1)
estado = 'excedido' if actual > tope else (
'cerca' if pct >= 80 else 'ok')
filas.append({
'clave': clave,
'label': LIMITE_LABEL.get(clave, clave),
'uso': actual,
'limite': tope,
'porcentaje': pct,
'estado': estado,
})
permitidos = modulos_licencia(licencia)
modulos = []
for clave in list(MODULOS_BASE) + list(MODULOS_GATEABLES):
incluido = permitidos is None or clave in permitidos
modulos.append({
'clave': clave,
'label': MODULO_LABEL.get(clave, clave),
'base': clave in MODULOS_BASE,
'incluido': incluido,
})
dias = dias_para_vencer(licencia)
aplicada = bool(licencia.get('clave'))
return {
'licencia': {
'plan': licencia.get('plan') or '',
'plan_label': (PLANES.get(licencia.get('plan')) or {}).get('label', ''),
'modo': licencia.get('modo') or 'aviso',
'cliente': licencia.get('cliente') or '',
'vence': licencia.get('vence') or '',
'dias_restantes': dias,
'aplicada': aplicada,
'bloqueo_activo': licencia_bloqueo_activo(licencia),
'aviso_vence': bool(dias is not None and 0 <= dias <= AVISO_DIAS),
},
'limites': filas,
'modulos': modulos,
'planes': [{'clave': k, 'label': v['label'], 'modulos': v['modulos']}
for k, v in PLANES.items()],
'aviso': ('Sin licencia aplicada — todas las funciones habilitadas.'
if not aplicada else None),
}
class SkeenLicenciaController(http.Controller):
@http.route(BASE + '/licencia', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def get_licencia(self, **kw):
try:
if str(request.httprequest.method).upper() == 'OPTIONS':
return _json({}, 200)
if not _puede_ver():
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
return _json({'status': 'success', **_estado(request.env)})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/licencia', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def put_licencia(self, **kw):
try:
if str(request.httprequest.method).upper() == 'OPTIONS':
return _json({}, 200)
if not _puede_editar():
return _json({'status': 'error',
'message': 'Solo administración puede aplicar licencias'}, 403)
data = _body()
env = request.env
if 'clave' in data:
clave = (data.get('clave') or '').strip()
if not clave:
# Quitar la licencia: todo vuelve a estar habilitado.
vacio = dict(LICENCIA_DEFAULT)
vacio['limites'] = {k: 0 for k in LIMITE_KEYS}
set_settings(env, {'licencia': vacio})
return _json({'status': 'success',
'message': 'Licencia retirada. Todo habilitado.',
**_estado(env)})
payload, error = verificar_clave_licencia(env, clave)
if error:
return _json({'status': 'error',
'message': '%s No se guardó ningún cambio.' % error}, 400)
modo = data.get('modo') or 'bloqueo'
lic = licencia_desde_payload(payload, clave, modo=modo)
set_settings(env, {'licencia': lic})
return _json({'status': 'success', 'message': 'Licencia aplicada.',
**_estado(env)})
# Cambios sin re-firmar (p. ej. pasar de aviso a bloqueo).
vals = {k: data[k] for k in ('modo', 'plan', 'cliente', 'vence',
'modulos', 'limites') if k in data}
if not vals:
return _json({'status': 'error', 'message': 'Nada que actualizar'}, 400)
set_settings(env, {'licencia': vals})
return _json({'status': 'success', 'message': 'Licencia actualizada.',
**_estado(env)})
except ValueError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 400)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/licencia/validar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def validar_licencia(self, **kw):
try:
if str(request.httprequest.method).upper() == 'OPTIONS':
return _json({}, 200)
if not _puede_ver():
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _body()
payload, error = verificar_clave_licencia(request.env, data.get('clave'))
dias = dias_para_vencer({'vence': (payload or {}).get('vence')})
return _json({
'status': 'success' if not error else 'error',
'valida': error is None,
'error': error,
'payload': payload,
'dias_restantes': dias,
'aviso_vence': bool(dias is not None and 0 <= dias <= AVISO_DIAS),
}, 200 if not error else 400)
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
"""CRUD de webhooks salientes y log de entregas (Configuración -> Webhooks)."""
import json
import secrets
from odoo import http
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_role, require_perm
from odoo.addons.skeen_plataforma.models.webhook import EVENTOS, ESTADOS
BASE = '/skeen/frontend/v1'
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _login():
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
def _eventos_list(data):
if isinstance(data.get('eventos'), list):
return [str(e).strip() for e in data['eventos'] if str(e).strip()]
if isinstance(data.get('eventos'), str):
return [e.strip() for e in data['eventos'].split(',') if e.strip()]
return []
class SkeenWebhooksController(http.Controller):
@http.route(BASE + '/webhooks', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def list_webhooks(self, **kw):
try:
service = request.env['skeen.webhook.service'].sudo()
hooks = request.env['skeen.webhook'].sudo().search([])
catalogo = [{'clave': c, 'label': l,
'ejemplo': service.payload_ejemplo(c)} for c, l in EVENTOS]
return _json({
'status': 'success',
'webhooks': [w.to_public_dict() for w in hooks],
'eventos': catalogo,
'estados': [{'clave': c, 'label': l} for c, l in ESTADOS],
'formato_firma': 'X-Skeen-Signature: sha256=<HMAC-SHA256 del cuerpo con el secreto>',
})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def create_webhook(self, **kw):
try:
data = _body()
url = (data.get('url') or '').strip()
if not (url.startswith('http://') or url.startswith('https://')):
return _json({'status': 'error',
'message': 'La URL debe empezar por http:// o https://'}, 400)
secreto = (data.get('secreto') or '').strip() or secrets.token_urlsafe(32)
headers = data.get('headers')
if isinstance(headers, dict):
headers = json.dumps(headers, ensure_ascii=False)
else:
headers = (headers or '').strip()
webhook = request.env['skeen.webhook'].sudo().create({
'name': (data.get('name') or '').strip() or 'Webhook',
'url': url,
'eventos': ','.join(_eventos_list(data)),
'activo': bool(data.get('activo', True)),
'secreto': secreto,
'headers': headers,
'notas': data.get('notas') or '',
'creado_por_login': _login(),
})
return _json({'status': 'success', 'webhook': webhook.to_public_dict(),
'secreto': secreto,
'message': 'Webhook creado.'})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks/<int:webhook_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def update_webhook(self, webhook_id, **kw):
try:
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
if not webhook.exists():
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
data = _body()
vals = {}
if 'name' in data and (data.get('name') or '').strip():
vals['name'] = data['name'].strip()
if 'url' in data and (data.get('url') or '').strip():
url = data['url'].strip()
if not (url.startswith('http://') or url.startswith('https://')):
return _json({'status': 'error',
'message': 'La URL debe empezar por http:// o https://'}, 400)
vals['url'] = url
if 'eventos' in data:
vals['eventos'] = ','.join(_eventos_list(data))
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if vals['activo']:
vals['fallos_consecutivos'] = 0
if 'secreto' in data and (data.get('secreto') or '').strip():
vals['secreto'] = data['secreto'].strip()
if 'headers' in data:
headers = data['headers']
vals['headers'] = json.dumps(headers, ensure_ascii=False) \
if isinstance(headers, dict) else (headers or '')
if 'notas' in data:
vals['notas'] = data.get('notas') or ''
if vals:
webhook.write(vals)
return _json({'status': 'success', 'webhook': webhook.to_public_dict()})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks/<int:webhook_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def delete_webhook(self, webhook_id, **kw):
try:
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
if not webhook.exists():
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
webhook.unlink()
return _json({'status': 'success', 'message': 'Webhook eliminado.'})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks/<int:webhook_id>/eventos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def list_webhook_eventos(self, webhook_id, **kw):
try:
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
if not webhook.exists():
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
domain = [('webhook_id', '=', webhook.id)]
if kw.get('estado'):
domain.append(('estado', '=', kw['estado']))
if kw.get('evento'):
domain.append(('evento', '=', kw['evento']))
try:
limite = min(max(int(kw.get('limit') or 100), 1), 500)
except (TypeError, ValueError):
limite = 100
Evento = request.env['skeen.webhook.evento'].sudo()
registros = Evento.search(domain, order='create_date desc, id desc',
limit=limite)
resumen = {c: 0 for c, _l in ESTADOS}
for grupo in Evento.read_group(domain, ['estado'], ['estado'], lazy=False):
if grupo.get('estado'):
resumen[grupo['estado']] = grupo.get('__count', 0)
return _json({'status': 'success',
'eventos': [e.to_public_dict() for e in registros],
'total': Evento.search_count(domain),
'resumen': resumen})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhooks/<int:webhook_id>/probar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def probar_webhook(self, webhook_id, **kw):
try:
webhook = request.env['skeen.webhook'].sudo().browse(webhook_id)
if not webhook.exists():
return _json({'status': 'error', 'message': 'Webhook no encontrado'}, 404)
ok, mensaje, evento = request.env['skeen.webhook.service'].sudo().probar(
webhook, login=_login())
return _json({'status': 'success' if ok else 'error', 'ok': ok,
'message': mensaje,
'evento': evento.to_public_dict() if evento else None},
200 if ok else 502)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/webhook_eventos/<int:evento_id>/reintentar', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('webhooks.editar')
def reintentar_evento(self, evento_id, **kw):
try:
evento = request.env['skeen.webhook.evento'].sudo().browse(evento_id)
if not evento.exists():
return _json({'status': 'error', 'message': 'Evento no encontrado'}, 404)
estado = request.env['skeen.webhook.service'].sudo().reintentar(evento)
return _json({'status': 'success', 'estado': estado,
'evento': evento.to_public_dict()})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Entrega de webhooks salientes. Sin eventos pendientes el cron no
hace nada; sin webhooks activos no se encola ningún evento. -->
<record id="cron_webhooks" model="ir.cron">
<field name="name">SKEEN Webhooks: entrega de eventos pendientes</field>
<field name="model_id" ref="model_skeen_webhook_service"/>
<field name="state">code</field>
<field name="code">model._cron_tick()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
from . import ir_http
from . import api_key
from . import webhook
from . import limites
from . import eventos

View File

@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
"""Claves de API de terceros (`skeen.api.key`).
La clave se muestra UNA sola vez al crearla: solo se guarda su hash SHA-256 y
un prefijo visible para identificarla. La comparación es en tiempo constante.
"""
import hashlib
import hmac
import json
import secrets
from odoo import api, fields, models
from odoo.exceptions import ValidationError
# Alcances disponibles (vacío = todos).
SCOPES = [
('citas.read', 'Consultar citas y disponibilidad'),
('citas.write', 'Crear y cancelar citas'),
('pacientes.read', 'Consultar pacientes y saldos'),
('servicios.read', 'Consultar el catálogo de servicios'),
('monedero.write', 'Redimir puntos del monedero'),
]
SCOPE_KEYS = tuple(k for k, _l in SCOPES)
SCOPE_LABEL = dict(SCOPES)
PREFIJO_LARGO = 14 # caracteres visibles de la clave
def hash_clave(raw):
return hashlib.sha256((raw or '').encode('utf-8')).hexdigest()
class SkeenApiKey(models.Model):
_name = 'skeen.api.key'
_description = 'Clave de API de terceros'
_order = 'create_date desc, id desc'
name = fields.Char(string='Nombre', required=True)
prefijo = fields.Char(string='Prefijo visible', required=True, index=True)
hash = fields.Char(string='Hash', required=True)
activo = fields.Boolean(string='Activa', default=True)
scopes = fields.Char(string='Alcances', default='[]',
help='Lista JSON de alcances. Vacío = todos.')
vence = fields.Date(string='Vence')
ultimo_uso = fields.Datetime(string='Último uso')
usos = fields.Integer(string='Usos', default=0)
notas = fields.Text(string='Notas')
creado_por_login = fields.Char(string='Creada por')
revocada_at = fields.Datetime(string='Revocada el')
revocada_por = fields.Char(string='Revocada por')
_sql_constraints = [
('hash_uniq', 'unique(hash)', 'Ya existe una clave de API con ese hash.'),
]
# ------------------------------------------------------------------
@api.model
def generar(self, vals, login=''):
"""Crea una clave y devuelve (record, clave_en_claro).
La clave en claro solo se devuelve aquí; no se persiste.
"""
raw = 'sk_' + secrets.token_urlsafe(32)
record = self.create({
'name': vals.get('name') or 'Clave de API',
'prefijo': raw[:PREFIJO_LARGO],
'hash': hash_clave(raw),
'scopes': json.dumps(self._normalizar_scopes(vals.get('scopes') or [])),
'vence': vals.get('vence') or False,
'notas': vals.get('notas') or '',
'creado_por_login': login or '',
'activo': True,
})
return record, raw
@api.model
def _normalizar_scopes(self, scopes):
if not isinstance(scopes, (list, tuple)):
return []
limpios = []
for s in scopes:
s = str(s or '').strip()
if s and s in SCOPE_KEYS and s not in limpios:
limpios.append(s)
return limpios
def scopes_list(self):
try:
val = json.loads(self.scopes or '[]')
except (ValueError, TypeError):
val = []
return val if isinstance(val, list) else []
def scopes_label(self):
"""Texto para la UI: 'todos' o la lista de etiquetas."""
scopes = self.scopes_list()
if not scopes:
return 'Todos'
return ', '.join(SCOPE_LABEL.get(s, s) for s in scopes)
def permite(self, scope):
"""True si la clave concede el alcance (vacío = todos). Nunca si inactiva."""
if not self.activo:
return False
if self.vence and self.vence < fields.Date.today():
return False
scopes = self.scopes_list()
if not scopes:
return True
return scope in scopes
def estado(self):
if self.revocada_at or not self.activo:
return 'revocada'
if self.vence and self.vence < fields.Date.today():
return 'vencida'
return 'activa'
def to_public_dict(self):
return {
'id': self.id,
'name': self.name,
'prefijo': self.prefijo,
'activo': self.activo,
'scopes': self.scopes_list(),
'scopes_label': self.scopes_label(),
'vence': self.vence.strftime('%Y-%m-%d') if self.vence else '',
'ultimo_uso': self.ultimo_uso.strftime('%Y-%m-%d %H:%M') if self.ultimo_uso else '',
'usos': self.usos,
'notas': self.notas or '',
'creado_por_login': self.creado_por_login or '',
'revocada_at': self.revocada_at.strftime('%Y-%m-%d %H:%M') if self.revocada_at else '',
'revocada_por': self.revocada_por or '',
'estado': self.estado(),
}
# ------------------------------------------------------------------
@api.model
def _autenticar(self, raw):
"""Devuelve la clave activa que corresponde a `raw` (o recordset vacío)."""
raw = (raw or '').strip()
if not raw:
return self.browse()
candidatas = self.search([
('prefijo', '=', raw[:PREFIJO_LARGO]), ('activo', '=', True),
])
digest = hash_clave(raw)
for key in candidatas:
if hmac.compare_digest(key.hash or '', digest):
return key
# Respaldo: por si el prefijo almacenado difiere, comparar por hash.
return self.search([('hash', '=', digest), ('activo', '=', True)], limit=1)
def _registrar_uso(self):
if not self:
return
self.sudo().write({
'ultimo_uso': fields.Datetime.now(),
'usos': self.usos + 1,
})
def action_revocar(self, login=''):
self.write({
'activo': False,
'revocada_at': fields.Datetime.now(),
'revocada_por': login or '',
})

View File

@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""Emisión de eventos de webhook desde los flujos existentes.
Cada override comprueba primero si hay algún webhook activo (una consulta
barata sobre una tabla normalmente vacía); si no lo hay, se comporta
EXACTAMENTE como antes y no crea ninguna fila. La entrega real es asíncrona
(cron), así que aquí solo se encola.
"""
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
def _emitir(env, evento, payload, clave_unica=None):
"""Encapsula la emisión para que NUNCA tumbe la operación de negocio."""
try:
service = env['skeen.webhook.service'].sudo()
if not service.hay_activos():
return
service.emitir(evento, payload, clave_unica=clave_unica)
except Exception:
_logger.exception('No se pudo emitir el evento de webhook %s', evento)
def _val(record, campo):
valor = getattr(record, campo, None)
if hasattr(valor, 'id'): # many2one
return valor.id or False
if hasattr(valor, 'name'): # recordset
return valor.name
if hasattr(valor, 'isoformat'): # fecha/datetime
return valor.isoformat()
return valor
def _cita_payload(cita):
return {
'id': cita.id,
'reference': cita.name,
'state': cita.state,
'date': cita.date.isoformat() if cita.date else '',
'patient_id': cita.partner_id.id,
'patient': cita.partner_id.name or '',
'service_id': cita.servicio_id.id if cita.servicio_id else None,
'servicio': cita.servicio_id.name if cita.servicio_id else '',
'doctor': cita.doctor_id.name if getattr(cita, 'doctor_id', None) else '',
}
class SkeenCita(models.Model):
_inherit = 'skeen.cita'
@api.model_create_multi
def create(self, vals_list):
citas = super().create(vals_list)
for cita in citas:
_emitir(self.env, 'cita.creada', _cita_payload(cita),
clave_unica='cita.creada:%s' % cita.id)
return citas
def write(self, vals):
if 'state' not in vals:
return super().write(vals)
previo = {c.id: c.state for c in self}
res = super().write(vals)
destinos = {
'confirmed': 'cita.confirmada',
'cancelled': 'cita.cancelada',
'no_show': 'cita.no_show',
'done': 'cita.completada',
}
for cita in self:
evento = destinos.get(cita.state)
if evento and previo.get(cita.id) != cita.state:
_emitir(self.env, evento, _cita_payload(cita),
clave_unica='%s:%s' % (evento, cita.id))
return res
class SkeenVenta(models.Model):
_inherit = 'skeen.venta'
def write(self, vals):
if 'state' not in vals and 'refunded' not in vals:
return super().write(vals)
previo = {v.id: (v.state, v.refunded) for v in self}
res = super().write(vals)
for venta in self:
antes = previo.get(venta.id, (None, None))
if venta.state == 'paid' and antes[0] != 'paid':
_emitir(self.env, 'venta.pagada', {
'id': venta.id, 'reference': venta.name, 'state': venta.state,
'total': _val(venta, 'total'), 'patient_id': venta.partner_id.id,
}, clave_unica='venta.pagada:%s' % venta.id)
if (venta.state == 'cancelled' and antes[0] != 'cancelled') or \
(venta.refunded and not antes[1]):
_emitir(self.env, 'venta.cancelada', {
'id': venta.id, 'reference': venta.name, 'state': venta.state,
'refunded': bool(venta.refunded), 'refund_amount': _val(venta, 'refund_amount'),
}, clave_unica='venta.cancelada:%s' % venta.id)
return res
class SkeenPago(models.Model):
_inherit = 'skeen.pago'
@api.model_create_multi
def create(self, vals_list):
pagos = super().create(vals_list)
for pago in pagos:
_emitir(self.env, 'pago.registrado', {
'id': pago.id, 'reference': pago.name, 'amount': pago.amount,
'payment_method': pago.payment_method, 'state': pago.state,
'patient_id': pago.partner_id.id if pago.partner_id else None,
}, clave_unica='pago.registrado:%s' % pago.id)
return pagos
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model_create_multi
def create(self, vals_list):
partners = super().create(vals_list)
for partner in partners:
if not partner.is_patient:
continue
_emitir(self.env, 'paciente.creado', {
'id': partner.id, 'name': partner.name,
'phone': partner.phone or '', 'email': partner.email or '',
'is_patient': True,
}, clave_unica='paciente.creado:%s' % partner.id)
return partners
class SkeenMembresia(models.Model):
_inherit = 'skeen.membresia'
def action_renovar(self, *args, **kwargs):
res = super().action_renovar(*args, **kwargs)
for membresia in self:
_emitir(self.env, 'membresia.renovada', {
'id': membresia.id,
'plan': membresia.plan_id.name if membresia.plan_id else '',
'estado': membresia.estado,
'periodo_actual_inicio': _val(membresia, 'periodo_actual_inicio'),
'periodo_actual_fin': _val(membresia, 'periodo_actual_fin'),
'patient_id': membresia.partner_id.id if membresia.partner_id else None,
}, clave_unica='membresia.renovada:%s:%s'
% (membresia.id, _val(membresia, 'periodo_actual_inicio')))
return res
class SkeenFormularioRespuesta(models.Model):
_inherit = 'skeen.formulario.respuesta'
def write(self, vals):
if vals.get('estado') != 'llenado':
return super().write(vals)
previo = {r.id: r.estado for r in self}
res = super().write(vals)
for respuesta in self:
if previo.get(respuesta.id) != 'llenado':
_emitir(self.env, 'formulario.llenado', {
'id': respuesta.id,
'plantilla': _val(getattr(respuesta, 'plantilla_id', None), 'name')
if getattr(respuesta, 'plantilla_id', None) else '',
'estado': respuesta.estado,
'patient_id': respuesta.partner_id.id if getattr(respuesta, 'partner_id', None) else None,
}, clave_unica='formulario.llenado:%s' % respuesta.id)
return res
class SkeenCompra(models.Model):
_inherit = 'skeen.compra'
def write(self, vals):
if 'estado' not in vals:
return super().write(vals)
previo = {c.id: c.estado for c in self}
res = super().write(vals)
for compra in self:
if compra.estado in ('recibida', 'recibida_parcial') \
and previo.get(compra.id) != compra.estado:
_emitir(self.env, 'compra.recibida', {
'id': compra.id,
'referencia': _val(compra, 'name') or _val(compra, 'referencia'),
'estado': compra.estado,
}, clave_unica='compra.recibida:%s:%s' % (compra.id, compra.estado))
return res

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""Autenticación `api_key` para las rutas públicas `/skeen/api/v1/*`.
Odoo no trae un método de autenticación `api_key`; las rutas de
`skeen_whatsapp/controllers/main.py` lo declaraban pero no existía, así que
TODAS las peticiones respondían AccessDenied. Aquí se implementa el método que
falta, sin cambiar ni el formato de la clave ni el de los encabezados:
1) CLave legacy de Hermes: `ir.config_parameter 'skeen.api.legacy_key'`
(sembrada con `skeen_api_key_2026`).
2) Claves del modelo `skeen.api.key` (hash, alcances, vencimiento, revocación).
La clave se lee de `Authorization: Bearer <clave>`, de `X-API-Key` o del
parámetro `api_key` (query/form), que es lo que ya usaba Hermes.
"""
import hmac
import json
from werkzeug.exceptions import Forbidden
from odoo import SUPERUSER_ID, models, http
from odoo.exceptions import AccessDenied
from odoo.http import request, Response
from odoo.addons.skeen_citas.clinic_settings import licencia_de, modulo_bloqueado
LEGACY_KEY_PARAM = 'skeen.api.legacy_key'
LEGACY_KEY_DEFAULT = 'skeen_api_key_2026'
# Rutas del frontend cuyo módulo se controla por licencia. Los módulos con
# bloque `activo` (compras, membresías, comisiones, recursos, notificaciones,
# reservas) ya responden 403 solos al apagarse, así que aquí solo hacen falta
# los que no tienen ese bloque.
RUTAS_LICENCIA = (
('/skeen/frontend/v1/formularios', 'formularios'),
('/skeen/frontend/v1/wacrm', 'whatsapp'),
)
def _respuesta_licencia():
"""Respuesta 403 JSON si la ruta pertenece a un módulo fuera de la licencia.
Devuelve None si la petición puede continuar. No aplica a OPTIONS (preflight
CORS) ni cuando no hay licencia en modo bloqueo."""
try:
if request.httprequest.method == 'OPTIONS':
return None
path = request.httprequest.path or ''
modulo = next((m for prefijo, m in RUTAS_LICENCIA if path.startswith(prefijo)), None)
if not modulo or not request.env:
return None
if not modulo_bloqueado(licencia_de(request.env), modulo):
return None
except Exception:
return None
cuerpo = json.dumps({'status': 'error',
'message': 'Módulo no incluido en tu plan'},
ensure_ascii=False)
return Response(cuerpo, status=403, mimetype='application/json',
headers={'Access-Control-Allow-Origin': '*'})
def extraer_clave(req):
"""Devuelve la clave de API de la petición (o '')."""
try:
headers = req.httprequest.headers
auth = headers.get('Authorization', '') or ''
if auth.lower().startswith('bearer '):
return auth[7:].strip()
for header in ('X-API-Key', 'X-Api-Key', 'Api-Key'):
valor = headers.get(header)
if valor:
return valor.strip()
args = req.httprequest.args
for parametro in ('api_key', 'api-key', 'apikey'):
if args.get(parametro):
return args.get(parametro).strip()
except Exception:
return ''
return ''
def clave_permite_scope(req, scope):
"""True si la clave de la petición concede el alcance `scope`.
La clave legacy de Hermes tiene todos los alcances. Una petición sin
`request.api_key` (p. ej. dentro de pruebas) se considera legacy.
"""
key = getattr(req, 'api_key', None)
if key is None:
return True
return key.permite(scope)
def exigir_scope(req, scope):
"""Lanza 403 (HTTP 403) si la clave no tiene el alcance indicado."""
if not clave_permite_scope(req, scope):
raise Forbidden(description='La clave de API no tiene el alcance "%s".' % scope)
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def _dispatch(cls, endpoint):
respuesta = _respuesta_licencia()
if respuesta is not None:
return respuesta
return super()._dispatch(endpoint)
@classmethod
def _auth_method_api_key(cls):
raw = extraer_clave(request)
if not raw:
raise AccessDenied('Falta la clave de API (Authorization: Bearer ...).')
ICP = request.env['ir.config_parameter'].sudo()
legacy = ICP.get_param(LEGACY_KEY_PARAM) or LEGACY_KEY_DEFAULT
if hmac.compare_digest(raw, legacy):
request.api_key = None
request.api_key_legacy = True
request.update_env(user=SUPERUSER_ID)
return
key = None
if 'skeen.api.key' in request.env:
key = request.env['skeen.api.key'].sudo()._autenticar(raw)
if not key:
raise AccessDenied('Clave de API inválida o revocada.')
request.api_key = key
request.api_key_legacy = False
key._registrar_uso()
request.update_env(user=SUPERUSER_ID)

View File

@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""Aplicación de los límites de la licencia.
Solo actúan con `modo='bloqueo'` y un límite > 0. Con `aviso` (default) o sin
licencia no bloquean nada. Nunca afectan tareas internas: se pueden saltar con
el contexto `skeen_licencia_skip=True` (cron, migraciones, importaciones).
"""
import logging
from datetime import date
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.addons.skeen_citas.clinic_settings import (
licencia_de, limite_licencia, licencia_bloqueo_activo,
)
_logger = logging.getLogger(__name__)
MENSAJES = {
'usuarios_max': 'usuarios',
'sucursales_max': 'sucursales',
'citas_mes_max': 'citas este mes',
'almacenamiento_mb': 'MB de almacenamiento',
}
def _skip(env):
return bool(env.context.get('skeen_licencia_skip')
or env.context.get('install_mode'))
def exigir_limite(env, clave, etiqueta, actual, agregando=1):
"""Lanza UserError si `actual + agregando` supera el límite de `clave`.
Con modo=aviso solo deja una advertencia en el log. Devuelve False si no
aplica (sin licencia, sin límite o contexto de salto).
"""
limite = limite_licencia(env, clave)
if limite <= 0 or _skip(env):
return False
licencia = licencia_de(env)
if not licencia_bloqueo_activo(licencia):
if actual + agregando > limite:
_logger.info('SKEEN licencia (aviso): %s excede el límite de %s '
'(%s + %s > %s)', etiqueta, limite, actual, agregando, limite)
return False
if actual + agregando > limite:
raise UserError(_(
'Tu plan permite hasta %(limite)s %(etiqueta)s y ya tienes '
'%(actual)s. Para ampliar el límite, contacta a tu proveedor y '
'solicita una licencia con un tope mayor.'
) % {'limite': limite, 'etiqueta': etiqueta, 'actual': actual})
return True
def _contar_usuarios(env):
return env['skeen.frontend.user'].sudo().search_count([('active', '=', True)])
def _contar_sucursales(env):
return env['skeen.sucursal'].sudo().search_count([('active', '=', True)])
def _contar_citas_mes(env):
hoy = date.today()
inicio = hoy.replace(day=1)
if hoy.month == 12:
fin = hoy.replace(year=hoy.year + 1, month=1, day=1)
else:
fin = hoy.replace(month=hoy.month + 1, day=1)
return env['skeen.cita'].sudo().search_count([
('date', '>=', inicio), ('date', '<', fin),
])
def _mb_almacenamiento(env):
env.cr.execute('SELECT COALESCE(SUM(file_size), 0) FROM ir_attachment')
total = env.cr.fetchone()[0] or 0
return total / (1024.0 * 1024.0)
class SkeenFrontendUser(models.Model):
_inherit = 'skeen.frontend.user'
@api.model_create_multi
def create(self, vals_list):
exigir_limite(self.env, 'usuarios_max', MENSAJES['usuarios_max'],
_contar_usuarios(self.env), len(vals_list))
return super().create(vals_list)
class SkeenSucursal(models.Model):
_inherit = 'skeen.sucursal'
@api.model_create_multi
def create(self, vals_list):
exigir_limite(self.env, 'sucursales_max', MENSAJES['sucursales_max'],
_contar_sucursales(self.env), len(vals_list))
return super().create(vals_list)
class SkeenCita(models.Model):
_inherit = 'skeen.cita'
@api.model_create_multi
def create(self, vals_list):
exigir_limite(self.env, 'citas_mes_max', MENSAJES['citas_mes_max'],
_contar_citas_mes(self.env), len(vals_list))
return super().create(vals_list)
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
@api.model_create_multi
def create(self, vals_list):
if limite_licencia(self.env, 'almacenamiento_mb') > 0 and not _skip(self.env):
nuevos_mb = sum(max(0, int(v.get('file_size') or 0)) for v in vals_list) \
/ (1024.0 * 1024.0)
exigir_limite(self.env, 'almacenamiento_mb',
MENSAJES['almacenamiento_mb'],
_mb_almacenamiento(self.env), nuevos_mb)
return super().create(vals_list)

View File

@@ -0,0 +1,367 @@
# -*- coding: utf-8 -*-
"""Webhooks salientes: outbox, entrega por cron, reintentos y log.
Sin webhooks activos suscritos a un evento NO se crea ninguna fila en el outbox
y el cron no hace nada: cero impacto en una instancia sin configurar. La entrega
es asíncrona (cron cada 5 minutos) para no añadir latencia a los flujos.
Firma: `X-Skeen-Signature: sha256=<HMAC-SHA256 del cuerpo con el secreto>`.
"""
import hashlib
import hmac
import json
import logging
import uuid
from datetime import timedelta
import requests
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# Catálogo de eventos emitibles (clave, etiqueta).
EVENTOS = [
('cita.creada', 'Cita creada'),
('cita.confirmada', 'Cita confirmada'),
('cita.cancelada', 'Cita cancelada'),
('cita.no_show', 'Cita no presentada'),
('cita.completada', 'Cita completada'),
('venta.pagada', 'Venta pagada'),
('venta.cancelada', 'Venta cancelada'),
('pago.registrado', 'Pago registrado'),
('paciente.creado', 'Paciente creado'),
('membresia.renovada', 'Membresía renovada'),
('formulario.llenado', 'Formulario llenado'),
('compra.recibida', 'Compra recibida'),
]
EVENTO_KEYS = tuple(k for k, _l in EVENTOS)
EVENTO_LABEL = dict(EVENTOS)
ESTADOS = [
('pendiente', 'Pendiente'),
('enviado', 'Enviado'),
('fallido', 'Fallido'),
('descartado', 'Descartado'),
]
MAX_INTENTOS = 5
BACKOFF_MIN = (1, 5, 30, 120, 360) # minutos entre reintentos
TIMEOUT = (5, 15) # (conexión, lectura) segundos
FALLOS_MAX_DEFAULT = 20
PROCESAR_POR_DEFECTO = 100
class SkeenWebhook(models.Model):
_name = 'skeen.webhook'
_description = 'Webhook saliente'
_order = 'name, id'
name = fields.Char(string='Nombre', required=True)
url = fields.Char(string='URL', required=True)
eventos = fields.Char(string='Eventos', default='',
help='Claves separadas por coma. Vacío = todos.')
activo = fields.Boolean(string='Activo', default=True)
secreto = fields.Char(string='Secreto', help='Con este secreto se firma cada entrega.')
headers = fields.Text(string='Cabeceras extra (JSON)')
notas = fields.Text(string='Notas')
creado_por_login = fields.Char(string='Creado por')
ultima_entrega_at = fields.Datetime(string='Última entrega')
fallos_consecutivos = fields.Integer(string='Fallos consecutivos', default=0)
def eventos_list(self):
return [e.strip() for e in (self.eventos or '').split(',') if e.strip()]
def suscrito(self, evento):
"""True si el webhook recibe ese evento (lista vacía = todos)."""
lista = self.eventos_list()
return (not lista) or (evento in lista)
def headers_dict(self):
try:
val = json.loads(self.headers or '{}')
except (ValueError, TypeError):
return {}
return val if isinstance(val, dict) else {}
def to_public_dict(self):
return {
'id': self.id,
'name': self.name,
'url': self.url,
'eventos': self.eventos_list(),
'eventos_label': 'Todos' if not self.eventos_list() else
', '.join(EVENTO_LABEL.get(e, e) for e in self.eventos_list()),
'activo': self.activo,
'tiene_secreto': bool(self.secreto),
'headers': self.headers_dict(),
'notas': self.notas or '',
'creado_por_login': self.creado_por_login or '',
'ultima_entrega_at': self.ultima_entrega_at.strftime('%Y-%m-%d %H:%M')
if self.ultima_entrega_at else '',
'fallos_consecutivos': self.fallos_consecutivos,
}
class SkeenWebhookEvento(models.Model):
_name = 'skeen.webhook.evento'
_description = 'Evento de webhook (outbox)'
_order = 'create_date desc, id desc'
webhook_id = fields.Many2one('skeen.webhook', string='Webhook', required=True,
ondelete='cascade', index=True)
evento = fields.Char(string='Evento', required=True, index=True)
payload = fields.Text(string='Payload (JSON)')
estado = fields.Selection(ESTADOS, string='Estado', default='pendiente',
required=True, index=True)
intentos = fields.Integer(string='Intentos', default=0)
proximo_intento = fields.Datetime(string='Próximo intento', index=True)
respuesta_codigo = fields.Integer(string='Código de respuesta')
respuesta_texto = fields.Text(string='Respuesta')
enviado_at = fields.Datetime(string='Enviado el')
clave_unica = fields.Char(string='Clave única', index=True)
_sql_constraints = [
('clave_unica_uniq', 'unique(clave_unica)',
'Ya existe un evento con esa clave (evita duplicados).'),
]
def to_public_dict(self):
return {
'id': self.id,
'webhook_id': self.webhook_id.id,
'webhook': self.webhook_id.name,
'evento': self.evento,
'evento_label': EVENTO_LABEL.get(self.evento, self.evento),
'estado': self.estado,
'estado_label': dict(ESTADOS).get(self.estado, self.estado),
'intentos': self.intentos,
'proximo_intento': self.proximo_intento.strftime('%Y-%m-%d %H:%M')
if self.proximo_intento else '',
'respuesta_codigo': self.respuesta_codigo or 0,
'respuesta_texto': (self.respuesta_texto or '')[:500],
'enviado_at': self.enviado_at.strftime('%Y-%m-%d %H:%M') if self.enviado_at else '',
'creado_at': self.create_date.strftime('%Y-%m-%d %H:%M') if self.create_date else '',
}
class SkeenWebhookService(models.AbstractModel):
_name = 'skeen.webhook.service'
_description = 'Servicio de webhooks salientes'
# ------------------------------------------------------------------
# Emisión al outbox
# ------------------------------------------------------------------
@api.model
def hay_activos(self):
return bool(self.env['skeen.webhook'].sudo().search_count([('activo', '=', True)]))
@api.model
def _suscriptores(self, evento):
activos = self.env['skeen.webhook'].sudo().search([('activo', '=', True)])
return activos.filtered(lambda w: w.suscrito(evento))
@api.model
def emitir(self, evento, payload, clave_unica=None, webhook_ids=None):
"""Encola el evento para los webhooks activos suscritos.
Sin webhooks activos suscritos no crea nada (cero impacto). Devuelve el
recordset de eventos creados.
"""
if evento not in EVENTO_KEYS and evento != 'prueba':
return self.env['skeen.webhook.evento'].browse()
if webhook_ids is None:
suscriptores = self._suscriptores(evento)
else:
suscriptores = self.env['skeen.webhook'].sudo().browse(webhook_ids)
suscriptores = suscriptores.filtered(lambda w: w.suscrito(evento))
if not suscriptores:
return self.env['skeen.webhook.evento'].browse()
Evento = self.env['skeen.webhook.evento'].sudo()
texto = json.dumps(payload or {}, ensure_ascii=False, default=str)
creados = Evento
ahora = fields.Datetime.now()
for webhook in suscriptores:
clave = clave_unica
if clave:
clave = '%s:w%s' % (clave, webhook.id)
if Evento.search_count([('clave_unica', '=', clave)]):
continue
creados |= Evento.create({
'webhook_id': webhook.id,
'evento': evento,
'payload': texto,
'estado': 'pendiente',
'proximo_intento': ahora,
'clave_unica': clave or None,
})
return creados
# ------------------------------------------------------------------
# Entrega
# ------------------------------------------------------------------
@api.model
def _cron_tick(self):
return self.procesar()
@api.model
def procesar(self, limite=PROCESAR_POR_DEFECTO):
"""Envía los eventos pendientes ya vencidos. Nunca detiene el cron."""
ahora = fields.Datetime.now()
pendientes = self.env['skeen.webhook.evento'].sudo().search([
('estado', '=', 'pendiente'),
('proximo_intento', '<=', ahora),
], order='proximo_intento asc, id asc', limit=limite)
resultado = {'revisados': len(pendientes), 'enviados': 0, 'fallidos': 0}
for evento in pendientes:
try:
estado = self._entregar(evento)
except Exception:
_logger.exception('Webhook evento %s: error inesperado', evento.id)
self._fallar(evento, 'Error inesperado al entregar')
estado = 'fallido'
if estado == 'enviado':
resultado['enviados'] += 1
elif estado == 'fallido':
resultado['fallidos'] += 1
if resultado['revisados']:
_logger.info('SKEEN webhooks: %s', resultado)
return resultado
@api.model
def firmar(self, secreto, cuerpo_bytes):
"""Firma `sha256=<hex>` del cuerpo con el secreto."""
if not secreto:
return ''
digest = hmac.new(secreto.encode('utf-8'), cuerpo_bytes, hashlib.sha256)
return 'sha256=' + digest.hexdigest()
@api.model
def _entregar(self, evento):
webhook = evento.webhook_id
if not webhook.activo:
evento.write({'estado': 'descartado',
'respuesta_texto': 'El webhook está desactivado.'})
return 'descartado'
cuerpo = (evento.payload or '{}').encode('utf-8')
headers = {
'Content-Type': 'application/json',
'X-Skeen-Event': evento.evento,
'X-Skeen-Delivery': uuid.uuid4().hex,
}
firma = self.firmar(webhook.secreto or '', cuerpo)
if firma:
headers['X-Skeen-Signature'] = firma
for k, v in (webhook.headers_dict() or {}).items():
headers[str(k)] = str(v)
try:
resp = requests.post(webhook.url, data=cuerpo, headers=headers,
timeout=TIMEOUT)
codigo = resp.status_code
texto = (resp.text or '')[:500]
except requests.RequestException as exc:
self._fallar(evento, 'No se pudo conectar: %s' % exc)
return 'fallido'
if 200 <= codigo < 300:
evento.write({
'estado': 'enviado', 'intentos': evento.intentos + 1,
'enviado_at': fields.Datetime.now(), 'respuesta_codigo': codigo,
'respuesta_texto': texto,
})
webhook.write({'ultima_entrega_at': fields.Datetime.now(),
'fallos_consecutivos': 0})
return 'enviado'
self._fallar(evento, texto or ('HTTP %s' % codigo), codigo=codigo)
return 'fallido'
@api.model
def _fallar(self, evento, texto, codigo=0):
intentos = evento.intentos + 1
vals = {'intentos': intentos, 'respuesta_codigo': codigo,
'respuesta_texto': (texto or '')[:500]}
if intentos >= MAX_INTENTOS:
vals['estado'] = 'fallido'
else:
espera = BACKOFF_MIN[min(intentos - 1, len(BACKOFF_MIN) - 1)]
vals['proximo_intento'] = fields.Datetime.now() + timedelta(minutes=espera)
evento.write(vals)
webhook = evento.webhook_id
fallos = webhook.fallos_consecutivos + 1
umbral = self._umbral_fallos()
vals_wh = {'fallos_consecutivos': fallos}
if umbral and fallos >= umbral:
vals_wh['activo'] = False
_logger.warning('SKEEN webhook %s desactivado tras %s fallos consecutivos',
webhook.id, fallos)
webhook.write(vals_wh)
@api.model
def _umbral_fallos(self):
raw = self.env['ir.config_parameter'].sudo().get_param('skeen.webhooks.fallos_max')
try:
return int(raw) if raw else FALLOS_MAX_DEFAULT
except (TypeError, ValueError):
return FALLOS_MAX_DEFAULT
# ------------------------------------------------------------------
# Acciones de UI
# ------------------------------------------------------------------
@api.model
def reintentar(self, evento):
"""Devuelve a la cola un evento fallido y lo intenta de inmediato."""
if evento.estado in ('enviado', 'descartado'):
return evento.estado
evento.write({'estado': 'pendiente', 'proximo_intento': fields.Datetime.now()})
return self._entregar(evento)
@api.model
def probar(self, webhook, login=''):
"""Envía un evento de prueba (ignora la suscripción) y devuelve el log."""
ejemplo = {
'evento': 'prueba',
'mensaje': 'Prueba de webhook SKEEN',
'webhook': webhook.name,
'generado_por': login or 'sistema',
}
evento = self.env['skeen.webhook.evento'].sudo().create({
'webhook_id': webhook.id,
'evento': 'prueba',
'payload': json.dumps(ejemplo, ensure_ascii=False),
'estado': 'pendiente',
'proximo_intento': fields.Datetime.now(),
})
estado = self._entregar(evento)
ok = estado == 'enviado'
mensaje = ('Entrega de prueba correcta.' if ok
else 'La entrega falló (código %s): %s'
% (evento.respuesta_codigo or 0, evento.respuesta_texto or ''))
return ok, mensaje, evento
@api.model
def payload_ejemplo(self, evento):
"""Payload de ejemplo de un evento, para el visor de la UI."""
payloads = {
'cita.creada': {'id': 123, 'reference': 'CITA-0123', 'state': 'pending',
'date': '2026-10-20', 'time': '10:30',
'patient_id': 55, 'patient': 'María López',
'service_id': 7, 'servicio': 'Limpieza facial'},
'cita.confirmada': {'id': 123, 'reference': 'CITA-0123', 'state': 'confirmed'},
'cita.cancelada': {'id': 123, 'reference': 'CITA-0123', 'state': 'cancelled'},
'cita.no_show': {'id': 123, 'reference': 'CITA-0123', 'state': 'no_show'},
'cita.completada': {'id': 123, 'reference': 'CITA-0123', 'state': 'done'},
'venta.pagada': {'id': 900, 'reference': 'V-00900', 'state': 'paid',
'total': 1500.0, 'patient_id': 55},
'venta.cancelada': {'id': 900, 'reference': 'V-00900', 'state': 'cancelled'},
'pago.registrado': {'id': 700, 'reference': 'P-00700', 'amount': 500.0,
'payment_method': 'cash', 'patient_id': 55},
'paciente.creado': {'id': 55, 'name': 'María López', 'phone': '+526641234567',
'is_patient': True},
'membresia.renovada': {'id': 30, 'plan': 'Mensual', 'estado': 'activa',
'periodo_actual_inicio': '2026-11-01',
'periodo_actual_fin': '2026-11-30'},
'formulario.llenado': {'id': 12, 'plantilla': 'Consentimiento',
'estado': 'llenado', 'patient_id': 55},
'compra.recibida': {'id': 44, 'referencia': 'OC-0044', 'estado': 'recibida'},
}
return payloads.get(evento, {'evento': evento})

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_api_key_user,skeen.api.key.user,model_skeen_api_key,base.group_user,1,1,1,1
access_skeen_webhook_user,skeen.webhook.user,model_skeen_webhook,base.group_user,1,1,1,1
access_skeen_webhook_evento_user,skeen.webhook.evento.user,model_skeen_webhook_evento,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_api_key_user skeen.api.key.user model_skeen_api_key base.group_user 1 1 1 1
3 access_skeen_webhook_user skeen.webhook.user model_skeen_webhook base.group_user 1 1 1 1
4 access_skeen_webhook_evento_user skeen.webhook.evento.user model_skeen_webhook_evento base.group_user 1 1 1 1

View File

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

View File

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