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

@@ -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'})