# -*- 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 `, `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'})