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