# -*- coding: utf-8 -*- """Portal público de reservas en línea (auto-agendado del cliente final). Endpoints anónimos (sin token de sesión) bajo `/skeen/frontend/v1/public/booking/`. nginx los sirve con el mismo proxy que el resto del API (`/api/odoo/...`). Reglas de oro: - Con `reservas_online.activo=False` TODO responde 404: nada cambia en la instancia ni se expone información. - No se expone ningún dato personal de pacientes: solo disponibilidad (horarios libres) y la propia cita vía token HMAC. - Rate limiting básico por IP y honeypot en la creación, para frenar bots. La disponibilidad se delega por completo en `skeen.cita.get_available_slots` (bloqueos, duración del servicio, solapes por médico, horario de la clínica y del médico): no se reimplementan reglas de agenda. """ import base64 import hashlib import hmac import json import logging import re import threading import time from datetime import datetime, time as dtime, timedelta import pytz from odoo import http from odoo.exceptions import ValidationError from odoo.http import request, Response from .auth import _get_secret from odoo.addons.skeen_citas.clinic_settings import ( get_settings as get_clinic_settings, get_reservas_online, brand_dict, ) from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario PREFIX = '/skeen/frontend/v1/public/booking' # Tiempo de vida del token de gestión de una cita (suficiente para ver/cancelar). TOKEN_TTL = 60 * 60 * 24 * 180 # 180 días TOKEN_KIND = 'booking' # Marcador visible/auditable del origen de la reserva. ORIGEN_MARCA = 'Reserva en línea' # Rate limiting en memoria por proceso. `workers=0` => un único proceso, por lo # que un dict con lock basta y evita meter Redis/tabla para algo tan básico. RATE_LIMITS = { 'slots': (20, 60), 'create': (5, 60), 'cita': (30, 60), 'cancel': (10, 60), } _RATE_LOCK = threading.Lock() _RATE_HITS = {} _EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') 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, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }, ) _logger = logging.getLogger(__name__) # Mensaje de error genérico para fallos inesperados: en un endpoint público no # se filtran mensajes internos (nombres de modelos/campos o trazas). _ERROR_INTERNO = 'No se pudo procesar la solicitud. Intenta de nuevo.' def _error_interno(exc): _logger.exception('Error en el portal público de reservas: %s', exc) return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500) def _b64url_encode(raw): return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') def _b64url_decode(s): return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) def _client_ip(): """IP del cliente. nginx reenvía X-Forwarded-For; si no, remote_addr.""" xff = request.httprequest.headers.get('X-Forwarded-For') or '' if xff: return xff.split(',')[0].strip() return request.httprequest.remote_addr or 'desconocida' def _rate_limited(bucket): """True si la IP ya agotó el cupo del bucket en la ventana. Registra el hit.""" limit, window = RATE_LIMITS.get(bucket, (30, 60)) key = (_client_ip(), bucket) ahora = time.time() with _RATE_LOCK: hits = [t for t in _RATE_HITS.get(key, []) if ahora - t < window] if len(hits) >= limit: _RATE_HITS[key] = hits return True hits.append(ahora) _RATE_HITS[key] = hits # Poda ligera para que el dict no crezca sin control con muchas IPs. if len(_RATE_HITS) > 5000: for k in list(_RATE_HITS): if not [t for t in _RATE_HITS[k] if ahora - t < window]: del _RATE_HITS[k] return False def _parse_fecha(valor): try: return datetime.strptime(str(valor).strip(), '%Y-%m-%d').date() except (TypeError, ValueError): return None def _parse_hora(valor): """'HH:MM' -> hora float. None si el formato no es válido.""" m = re.match(r'^(\d{1,2}):(\d{2})$', str(valor or '').strip()) if not m: return None h, mi = int(m.group(1)), int(m.group(2)) if not (0 <= h <= 23 and 0 <= mi <= 59): return None return h + mi / 60.0 def _normaliza_telefono(valor): """Teléfono normalizado para guardar/buscar. Conserva un '+' inicial.""" raw = str(valor or '').strip() mas = raw.startswith('+') digitos = re.sub(r'\D', '', raw) return ('+' + digitos) if (mas and digitos) else digitos def _selection_label(env, model_name, fname, value): """Etiqueta legible de un Selection, incluidos los dinámicos (método). `branch` resuelve su selección con un método, así que `dict(field.selection)` no sirve: se usa `_description_selection`, que siempre devuelve las tuplas. """ field = env[model_name]._fields.get(fname) if not field: return value try: opciones = field._description_selection(env) except Exception: opciones = field.selection if isinstance(field.selection, (list, tuple)) else [] return dict(opciones).get(value, value) def _tz_clinica(settings): nombre = (settings.get('zona_horaria') or 'America/Tijuana').strip() try: return pytz.timezone(nombre) except Exception: return pytz.timezone('America/Tijuana') class SkeenPublicBookingController(http.Controller): """Portal público de reservas en línea.""" # ------------------------------------------------------------------ # Infra interna # ------------------------------------------------------------------ def _activo(self): try: return bool(get_reservas_online(request.env).get('activo')) except Exception: return False def _deshabilitado(self): return _json({'status': 'error', 'message': 'Reservas en línea no disponibles'}, 404) def _config(self): return get_reservas_online(request.env) def _settings(self): return get_clinic_settings(request.env) def _brand(self): """Brand vigente con defaults, para el portal sin sesión.""" guardado = brand_dict(request.env) out = { 'nombre': self._settings().get('nombre') or 'Clínica', 'logo_claro': '/brand/logo-blanco.svg', 'logo_oscuro': '/brand/logo-negro.svg', 'colores': {'primario': '', 'acento': ''}, } if isinstance(guardado, dict): if guardado.get('nombre'): out['nombre'] = str(guardado['nombre']) for k in ('logo_claro', 'logo_oscuro'): if guardado.get(k): out[k] = str(guardado[k]) colores = guardado.get('colores') if isinstance(colores, dict): for c in ('primario', 'acento'): if colores.get(c): out['colores'][c] = str(colores[c]) return out def _sign_token(self, cita): payload = {'cid': cita.id, 'kind': TOKEN_KIND, 'exp': int(time.time()) + TOKEN_TTL} body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8')) sig = hmac.new(_get_secret(request.env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest() return '%s.%s' % (body, sig) def _verify_token(self, token): if not token or '.' not in str(token): return None try: body, sig = str(token).rsplit('.', 1) expected = hmac.new(_get_secret(request.env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): return None payload = json.loads(_b64url_decode(body).decode('utf-8')) except Exception: return None if payload.get('kind') != TOKEN_KIND: return None if int(payload.get('exp', 0)) < int(time.time()): return None return payload def _cita_de_token(self, token): payload = self._verify_token(token) if not payload: return None cita = request.env['skeen.cita'].sudo().browse(int(payload.get('cid') or 0)) if not cita.exists() or cita.booking_source != 'public_portal': return None return cita def _encolar_confirmacion(self, cita): """Hook Fase 2.4: avisar al cliente que la clínica confirmará su cita. A propósito es un no-op: la Fase 2.4 enviará el mensaje de confirmación por WhatsApp/WACRM (usando `cita.wacrm_conversation_id` o el teléfono del paciente) cuando esas credenciales estén configuradas. Se deja el punto de extensión listo, sin acoplar el portal público a WhatsApp todavía. """ return False # ------------------------------------------------------------------ # 1. Configuración pública del portal # ------------------------------------------------------------------ @http.route(PREFIX + '/config', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def booking_config(self, **kw): if request.httprequest.method == 'OPTIONS': return _json({}, 200) if not self._activo(): return self._deshabilitado() cfg = self._config() settings = self._settings() env = request.env sucursales = env['skeen.sucursal'].sudo().search([('active', '=', True)], order='sequence, name') categorias = env['skeen.service.category'].sudo().search([('active', '=', True)], order='sequence, name') servicios = env['skeen.servicio'].sudo().search([('active', '=', True)], order='sequence, name') medicos = [] if cfg['permitir_eleccion_doctor']: empleados = env['hr.employee'].sudo().search([ ('active', '=', True), ('staff_type', 'in', ('doctor', 'cosmetologa')), ], order='name') medicos = [{'id': e.id, 'nombre': e.name, 'staff_type': e.staff_type or 'doctor'} for e in empleados] _pack, terms = resolver_glosario(env) return _json({ 'activo': True, 'brand': self._brand(), 'terms': terms, 'sucursales': [{'code': s.code, 'name': s.name, 'address': s.address or '', 'phone': s.phone or ''} for s in sucursales], 'categorias': [{'code': c.code, 'name': c.name} for c in categorias], 'servicios': [{'id': s.id, 'nombre': s.name, 'categoria': s.category, 'precio': s.price, 'duracion_min': s.duration_min} for s in servicios], 'medicos': medicos, 'config': { 'max_dias_futuro': cfg['max_dias_futuro'], 'min_horas_anticipacion': cfg['min_horas_anticipacion'], 'permite_sucursal': cfg['permitir_sucursal'], 'permite_eleccion_doctor': cfg['permitir_eleccion_doctor'], 'requiere_confirmacion': cfg['requiere_confirmacion'], 'permitir_cancelar': cfg['permitir_cancelar'], 'cancelar_hasta_horas': cfg['cancelar_hasta_horas'], 'telefono_contacto': cfg['telefono_contacto'], 'titulo': cfg['titulo'], 'descripcion': cfg['descripcion'], 'mensaje_confirmacion': cfg['mensaje_confirmacion'], 'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana', }, }) # ------------------------------------------------------------------ # 2. Disponibilidad de horarios # ------------------------------------------------------------------ @http.route(PREFIX + '/slots', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def booking_slots(self, **kw): if request.httprequest.method == 'OPTIONS': return _json({}, 200) if not self._activo(): return self._deshabilitado() if _rate_limited('slots'): return _json({'status': 'error', 'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429) try: servicio_id = int(kw.get('servicio_id') or 0) fecha = _parse_fecha(kw.get('fecha')) doctor_id = int(kw.get('medico_id') or 0) or None if not servicio_id or not fecha: return _json({'status': 'error', 'message': 'Faltan servicio_id o fecha (YYYY-MM-DD)'}, 400) cfg = self._config() settings = self._settings() tz = _tz_clinica(settings) ahora = datetime.now(tz) servicio = request.env['skeen.servicio'].sudo().browse(servicio_id) if not servicio.exists() or not servicio.active: return _json({'status': 'error', 'message': 'Servicio no disponible'}, 404) if fecha < ahora.date(): return _json({'status': 'error', 'message': 'La fecha ya pasó'}, 400) if fecha > ahora.date() + timedelta(days=cfg['max_dias_futuro']): return _json({'status': 'error', 'message': 'La fecha está fuera del rango permitido'}, 400) if doctor_id: doctor = request.env['hr.employee'].sudo().browse(doctor_id) if not doctor.exists() or not doctor.active or \ doctor.staff_type not in ('doctor', 'cosmetologa'): return _json({'status': 'error', 'message': 'Especialista no disponible'}, 404) slots = request.env['skeen.cita'].sudo().get_available_slots( fecha, servicio_id, doctor_id=doctor_id) # Respeta el mínimo de anticipación (en la zona horaria de la clínica). minimo = ahora + timedelta(hours=cfg['min_horas_anticipacion']) disponibles = [] for s in slots: hh = int(s['time']) mm = int(round((s['time'] - hh) * 60)) inicio = tz.localize(datetime.combine(fecha, dtime(hh, mm))) if inicio >= minimo: disponibles.append(s['time_str']) return _json({ 'status': 'success', 'slots': disponibles, 'medico_id': doctor_id, 'duracion_min': servicio.duration_min or 30, }) except Exception as e: return _error_interno(e) # ------------------------------------------------------------------ # 3. Crear la reserva # ------------------------------------------------------------------ @http.route(PREFIX + '/create', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) def booking_create(self, **kw): if request.httprequest.method == 'OPTIONS': return _json({}, 200) if not self._activo(): return self._deshabilitado() if _rate_limited('create'): return _json({'status': 'error', 'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429) try: data = json.loads(request.httprequest.get_data(as_text=True) or '{}') except Exception: data = {} # Honeypot: campo oculto que solo un bot rellena. if str(data.get('hp') or '').strip(): return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400) try: cfg = self._config() settings = self._settings() tz = _tz_clinica(settings) ahora = datetime.now(tz) nombre = str(data.get('nombre') or '').strip() if not (2 <= len(nombre) <= 120): return _json({'status': 'error', 'message': 'Escribe tu nombre (2 a 120 caracteres)'}, 400) telefono = _normaliza_telefono(data.get('telefono')) digitos = re.sub(r'\D', '', telefono) if not (10 <= len(digitos) <= 15): return _json({'status': 'error', 'message': 'Escribe un teléfono válido (10 dígitos)'}, 400) email = str(data.get('email') or '').strip() if email and not _EMAIL_RE.match(email): return _json({'status': 'error', 'message': 'El email no es válido'}, 400) notas = str(data.get('notas') or '').strip()[:500] servicio_id = int(data.get('servicio_id') or 0) fecha = _parse_fecha(data.get('fecha')) hora = _parse_hora(data.get('hora')) doctor_id = int(data.get('medico_id') or 0) or None if not servicio_id or not fecha or hora is None: return _json({'status': 'error', 'message': 'Faltan servicio, fecha u hora'}, 400) servicio = request.env['skeen.servicio'].sudo().browse(servicio_id) if not servicio.exists() or not servicio.active: return _json({'status': 'error', 'message': 'Servicio no disponible'}, 404) if fecha < ahora.date(): return _json({'status': 'error', 'message': 'La fecha ya pasó'}, 400) if fecha > ahora.date() + timedelta(days=cfg['max_dias_futuro']): return _json({'status': 'error', 'message': 'La fecha está fuera del rango permitido'}, 400) hh = int(hora) mm = int(round((hora - hh) * 60)) inicio = tz.localize(datetime.combine(fecha, dtime(hh, mm))) if inicio < ahora + timedelta(hours=cfg['min_horas_anticipacion']): return _json({'status': 'error', 'message': 'La hora elegida ya no está disponible'}, 409) if doctor_id: doctor = request.env['hr.employee'].sudo().browse(doctor_id) if not doctor.exists() or not doctor.active or \ doctor.staff_type not in ('doctor', 'cosmetologa'): return _json({'status': 'error', 'message': 'Especialista no disponible'}, 404) # El horario debe ser uno de los disponibles según la agenda real. slots = request.env['skeen.cita'].sudo().get_available_slots( fecha, servicio_id, doctor_id=doctor_id) if not any(abs(hora - s['time']) < 1e-6 for s in slots): return _json({'status': 'error', 'message': 'Ese horario ya no está disponible'}, 409) # Sucursal: la elegida (si aplica) o la predeterminada de la clínica. Sucursal = request.env['skeen.sucursal'].sudo() branch = None if cfg['permitir_sucursal'] and data.get('sucursal'): code = str(data.get('sucursal')).strip() suc = Sucursal.search([('code', '=', code), ('active', '=', True)], limit=1) if not suc: return _json({'status': 'error', 'message': 'Sucursal no disponible'}, 404) branch = suc.code if not branch: branch = Sucursal.default_code() # Paciente: se reutiliza por teléfono; si no existe se crea mínimo. Partner = request.env['res.partner'].sudo() variantes = {telefono, digitos, '52' + digitos, '+52' + digitos} partner = Partner.search([ ('is_patient', '=', True), '|', ('phone', 'in', list(variantes)), ('mobile', 'in', list(variantes)), ], limit=1) if not partner: partner = Partner.search([ ('is_patient', '=', True), '|', ('phone', 'ilike', digitos), ('mobile', 'ilike', digitos), ], limit=1) creado_paciente = False if not partner: partner_vals = {'name': nombre, 'phone': telefono, 'is_patient': True, 'source': 'other'} if email: partner_vals['email'] = email try: tag = request.env['skeen.patient.tag'].sudo().search( [('name', '=ilike', ORIGEN_MARCA)], limit=1) if not tag: tag = request.env['skeen.patient.tag'].sudo().search( [('name', 'ilike', 'reserva')], limit=1) if tag: partner_vals['tag_ids'] = [(6, 0, [tag.id])] except Exception: pass partner = Partner.create(partner_vals) creado_paciente = True state = 'pending' if cfg['requiere_confirmacion'] else 'confirmed' nota = ORIGEN_MARCA if notas: nota = '%s\n%s' % (ORIGEN_MARCA, notas) cita_vals = { 'partner_id': partner.id, 'servicio_id': servicio_id, 'date': fecha.strftime('%Y-%m-%d'), 'time': hora, 'branch': branch, 'medium': 'onsite', 'state': state, 'notes': nota, 'booking_source': 'public_portal', } if doctor_id: cita_vals['doctor_id'] = doctor_id try: cita = request.env['skeen.cita'].sudo().create(cita_vals) except ValidationError as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 409) # Auditoría: quién/cuándo/cómo entró la reserva (nunca tumba la op.). try: cita.sudo().message_post( subject='Reserva en línea', body=('Reserva creada desde el portal público.
' 'IP: %s
Teléfono: %s' % (_client_ip(), telefono)), message_type='comment') cita.sudo()._log_state('', state, user='Portal en línea', note='Reserva en línea (portal)') except Exception: pass # Hook Fase 2.4: aviso de confirmación por WhatsApp/WACRM. if cfg['requiere_confirmacion']: self._encolar_confirmacion(cita) return _json({ 'ok': True, 'folio': cita.name, 'fecha': cita.date.strftime('%Y-%m-%d'), 'hora': cita.time_str, 'medico': cita.doctor_id.name if cita.doctor_id else None, 'servicio': cita.servicio_id.name, 'sucursal': _selection_label(request.env, 'skeen.cita', 'branch', cita.branch), 'estado': cita.state, 'requiere_confirmacion': cfg['requiere_confirmacion'], 'mensaje_confirmacion': cfg['mensaje_confirmacion'], 'token_de_gestion': self._sign_token(cita), }, 201) except Exception as e: request.env.cr.rollback() return _error_interno(e) # ------------------------------------------------------------------ # 4. Consultar una cita por token # ------------------------------------------------------------------ @http.route(PREFIX + '/cita', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def booking_cita(self, **kw): if request.httprequest.method == 'OPTIONS': return _json({}, 200) if not self._activo(): return self._deshabilitado() if _rate_limited('cita'): return _json({'status': 'error', 'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429) try: cita = self._cita_de_token(kw.get('token')) if not cita: return _json({'status': 'error', 'message': 'Enlace no válido o expirado'}, 404) cfg = self._config() settings = self._settings() tz = _tz_clinica(settings) ahora = datetime.now(tz) inicio = tz.localize(datetime.combine( cita.date, dtime(int(cita.time), int(round((cita.time % 1) * 60))))) puede_cancelar = bool( cfg['permitir_cancelar'] and cita.state in ('pending', 'confirmed') and inicio - ahora > timedelta(hours=cfg['cancelar_hasta_horas'])) return _json({ 'status': 'success', 'folio': cita.name, 'fecha': cita.date.strftime('%Y-%m-%d'), 'hora': cita.time_str, 'servicio': cita.servicio_id.name, 'medico': cita.doctor_id.name if cita.doctor_id else None, 'sucursal': _selection_label(request.env, 'skeen.cita', 'branch', cita.branch), 'estado': cita.state, 'estado_label': _selection_label(request.env, 'skeen.cita', 'state', cita.state), 'puede_cancelar': puede_cancelar, 'permitir_cancelar': cfg['permitir_cancelar'], 'cancelar_hasta_horas': cfg['cancelar_hasta_horas'], 'telefono_contacto': cfg['telefono_contacto'], }) except Exception as e: return _error_interno(e) # ------------------------------------------------------------------ # 5. Cancelar una cita por token # ------------------------------------------------------------------ @http.route(PREFIX + '/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) def booking_cancel(self, **kw): if request.httprequest.method == 'OPTIONS': return _json({}, 200) if not self._activo(): return self._deshabilitado() if _rate_limited('cancel'): return _json({'status': 'error', 'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429) try: data = json.loads(request.httprequest.get_data(as_text=True) or '{}') except Exception: data = {} try: cita = self._cita_de_token(data.get('token')) if not cita: return _json({'status': 'error', 'message': 'Enlace no válido o expirado'}, 404) cfg = self._config() if not cfg['permitir_cancelar']: return _json({'status': 'error', 'message': 'La cancelación en línea no está disponible. ' 'Contacta a la clínica.'}, 403) if cita.state not in ('pending', 'confirmed'): return _json({'status': 'error', 'message': 'Esta cita ya no se puede cancelar en línea.'}, 409) settings = self._settings() tz = _tz_clinica(settings) ahora = datetime.now(tz) inicio = tz.localize(datetime.combine( cita.date, dtime(int(cita.time), int(round((cita.time % 1) * 60))))) if inicio - ahora <= timedelta(hours=cfg['cancelar_hasta_horas']): return _json({'status': 'error', 'message': 'Ya no es posible cancelar en línea: faltan menos de ' '%d horas. Contacta a la clínica.' % cfg['cancelar_hasta_horas']}, 409) motivo = str(data.get('motivo') or '').strip()[:300] cita.write({'cancellation_reason': motivo or 'Cancelada por el cliente en el portal'}) cita.set_state('cancelled', user='Portal en línea', note=motivo or 'Cancelada en el portal público') try: cita.sudo().message_post( subject='Cancelación en línea', body='Cancelada desde el portal público. IP: %s' % _client_ip(), message_type='comment') except Exception: pass return _json({'status': 'success', 'ok': True, 'folio': cita.name, 'estado': 'cancelled'}) except Exception as e: request.env.cr.rollback() return _error_interno(e)