Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/controllers/main.py

279 lines
12 KiB
Python

# -*- 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, 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."""
@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 _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))
for slot in day_slots:
slots.append({
'date': current_date.strftime('%Y-%m-%d'),
'time': slot['time_str'],
'datetime': f"{current_date.strftime('%Y-%m-%d')} {slot['time_str']}",
})
current_date += timedelta(days=1)
return _json({'status': 'success', 'slots': slots})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 400)
@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:
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
if not partner:
partner = request.env['res.partner'].create({
'name': name,
'phone': phone,
'is_patient': True,
'source': 'whatsapp',
})
time_parts = str(time).split(':')
time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0
vals = {
'partner_id': partner.id,
'servicio_id': int(service_id),
'date': date,
'time': time_float,
'state': 'confirmed',
}
if doctor_id:
vals['doctor_id'] = int(doctor_id)
if duration:
vals['duration'] = int(duration)
cita = request.env['skeen.cita'].create(vals)
return _json({
'status': 'success',
'booking_id': cita.id,
'reference': cita.name,
'patient_id': partner.patient_id if 'patient_id' in partner._fields else partner.id,
})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 400)
@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 _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 = request.env['skeen.monedero'].search(
[('partner_id', '=', partner.id)], limit=1)
return _json({
'status': 'success',
'pending_balance': pending_balance,
'pending_invoices': [{
'id': c.id,
'reference': c.name,
'service': c.servicio_id.name,
'amount': c.servicio_price,
'date': c.date.strftime('%Y-%m-%d'),
} 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 _json({'status': 'error', 'message': str(e)}, 400)
@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 _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
monedero = request.env['skeen.monedero'].search(
[('partner_id', '=', partner.id)], limit=1)
if not monedero:
return _json({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
monedero.redeem_points(int(points))
return _json({
'status': 'success',
'remaining_points': monedero.points,
'remaining_mxn': monedero.equivalent_mxn,
})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 400)
@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),
('state', 'in', ('pending', 'confirmed')),
]
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 _json({'status': 'error', 'message': 'No se encontró cita activa'}, 404)
cita.write({'state': 'cancelled'})
return _json({'status': 'success', 'cancelled_booking': cita.name})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 400)
@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."""
denied = _scope_ok('servicios.read')
if denied:
return denied
try:
services = request.env['skeen.servicio'].search([('active', '=', True)])
return _json({
'status': 'success',
'services': [{
'id': s.id,
'code': s.code,
'name': s.name,
'category': s.category,
'price': s.price,
'package_price': s.package_price,
'package_sessions': s.package_sessions,
'package_notes': s.package_notes or '',
'duration_min': s.duration_min,
'description': s.description,
} for s in services],
})
except Exception as e:
return _json({'status': 'error', 'message': str(e)}, 400)
@http.route('/skeen/api/v1/health', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
def health_check(self, **kw):
"""Health check para monitoreo (sin autenticación)."""
return _json({'status': 'ok', 'service': 'skeen-odoo-api', 'version': '2.0.0'})