fase 2.3: portal publico de reservas en linea (5 endpoints anonimos con rate limit y token HMAC, wizard movil, gestion/cancelacion por enlace, distintivo en agenda, editor y enlace publico en Configuracion); apagado por defecto
This commit is contained in:
@@ -158,6 +158,110 @@ def get_impuestos(env):
|
||||
"""Bloque de impuestos vigente (copia). Fuente única para modelos y API."""
|
||||
return normalizar_impuestos(get_settings(env).get('impuestos'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reservas en línea (portal público de auto-agendado).
|
||||
#
|
||||
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
||||
# oro: con `activo=False` NADA cambia en la instancia (el portal público
|
||||
# responde 404 y el frontend no muestra enlaces). Por eso el default es apagado.
|
||||
# ---------------------------------------------------------------------------
|
||||
RESERVAS_ONLINE_DEFAULT = {
|
||||
'activo': False,
|
||||
'titulo': 'Reserva tu cita en línea',
|
||||
'descripcion': '',
|
||||
'requiere_confirmacion': True,
|
||||
'permitir_eleccion_doctor': True,
|
||||
'permitir_sucursal': False,
|
||||
'max_dias_futuro': 60,
|
||||
'min_horas_anticipacion': 2,
|
||||
'permitir_cancelar': True,
|
||||
'cancelar_hasta_horas': 24,
|
||||
'mensaje_confirmacion': '',
|
||||
'telefono_contacto': '',
|
||||
}
|
||||
|
||||
# Rangos razonables de validación del PUT (días / horas).
|
||||
RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX = 1, 365
|
||||
RESERVAS_MIN_ANTICIPACION_MAX = 168 # 1 semana
|
||||
RESERVAS_CANCELAR_HASTA_MAX = 168 # 1 semana
|
||||
|
||||
|
||||
def _int(valor, por_defecto=0):
|
||||
try:
|
||||
return int(valor)
|
||||
except (TypeError, ValueError):
|
||||
return por_defecto
|
||||
|
||||
|
||||
def normalizar_reservas_online(raw):
|
||||
"""Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza."""
|
||||
out = dict(RESERVAS_ONLINE_DEFAULT)
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
|
||||
'permitir_sucursal', 'permitir_cancelar'):
|
||||
out[k] = _bool(raw.get(k), RESERVAS_ONLINE_DEFAULT[k])
|
||||
out['titulo'] = (str(raw.get('titulo') or '').strip()
|
||||
or RESERVAS_ONLINE_DEFAULT['titulo'])[:120]
|
||||
out['descripcion'] = str(raw.get('descripcion') or '')[:500]
|
||||
out['mensaje_confirmacion'] = str(raw.get('mensaje_confirmacion') or '')[:500]
|
||||
out['telefono_contacto'] = str(raw.get('telefono_contacto') or '').strip()[:40]
|
||||
out['max_dias_futuro'] = min(max(_int(raw.get('max_dias_futuro'),
|
||||
RESERVAS_ONLINE_DEFAULT['max_dias_futuro']),
|
||||
RESERVAS_MAX_DIAS_MIN), RESERVAS_MAX_DIAS_MAX)
|
||||
out['min_horas_anticipacion'] = min(max(_int(raw.get('min_horas_anticipacion'),
|
||||
RESERVAS_ONLINE_DEFAULT['min_horas_anticipacion']),
|
||||
0), RESERVAS_MIN_ANTICIPACION_MAX)
|
||||
out['cancelar_hasta_horas'] = min(max(_int(raw.get('cancelar_hasta_horas'),
|
||||
RESERVAS_ONLINE_DEFAULT['cancelar_hasta_horas']),
|
||||
0), RESERVAS_CANCELAR_HASTA_MAX)
|
||||
return out
|
||||
|
||||
|
||||
def validar_reservas_online(raw):
|
||||
"""Valida el bloque `reservas_online` de un PUT. Lanza ValueError si algo no
|
||||
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
|
||||
para que el merge no reinicie lo que el PUT no tocó."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('El bloque de reservas en línea debe ser un objeto')
|
||||
out = {}
|
||||
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
|
||||
'permitir_sucursal', 'permitir_cancelar'):
|
||||
if k in raw:
|
||||
if not isinstance(raw[k], bool):
|
||||
raise ValueError('El campo %s debe ser verdadero o falso' % k)
|
||||
out[k] = raw[k]
|
||||
for k, limite in (('titulo', 120), ('descripcion', 500),
|
||||
('mensaje_confirmacion', 500), ('telefono_contacto', 40)):
|
||||
if k in raw:
|
||||
if not isinstance(raw[k], str):
|
||||
raise ValueError('El campo %s debe ser texto' % k)
|
||||
if len(raw[k].strip()) > limite:
|
||||
raise ValueError('El campo %s es demasiado largo (máx. %d)' % (k, limite))
|
||||
out[k] = raw[k].strip()
|
||||
if 'titulo' in raw and not raw['titulo'].strip():
|
||||
raise ValueError('El título no puede estar vacío')
|
||||
rangos = (
|
||||
('max_dias_futuro', RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX),
|
||||
('min_horas_anticipacion', 0, RESERVAS_MIN_ANTICIPACION_MAX),
|
||||
('cancelar_hasta_horas', 0, RESERVAS_CANCELAR_HASTA_MAX),
|
||||
)
|
||||
for k, lo, hi in rangos:
|
||||
if k in raw:
|
||||
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
|
||||
raise ValueError('El campo %s debe ser un número entero' % k)
|
||||
if not (lo <= raw[k] <= hi):
|
||||
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
|
||||
out[k] = raw[k]
|
||||
return out
|
||||
|
||||
|
||||
def get_reservas_online(env):
|
||||
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
|
||||
return normalizar_reservas_online(get_settings(env).get('reservas_online'))
|
||||
|
||||
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
||||
AGENDA_START_DEFAULT = 8.0
|
||||
AGENDA_END_DEFAULT = 19.0
|
||||
@@ -198,6 +302,7 @@ def get_settings(env):
|
||||
'moneda_base': MONEDA_DEFAULT,
|
||||
'nota_ticket': '',
|
||||
'impuestos': dict(IMPUESTOS_DEFAULT),
|
||||
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
|
||||
}
|
||||
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
||||
if raw:
|
||||
@@ -210,6 +315,7 @@ def get_settings(env):
|
||||
if k in guardado and guardado[k] is not None:
|
||||
out[k] = str(guardado[k])
|
||||
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
|
||||
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
|
||||
return out
|
||||
|
||||
|
||||
@@ -226,6 +332,10 @@ def set_settings(env, vals):
|
||||
if bloque is not None:
|
||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||
actual['impuestos'] = {**actual['impuestos'], **bloque}
|
||||
if 'reservas_online' in vals:
|
||||
bloque = validar_reservas_online(vals['reservas_online'])
|
||||
if bloque is not None:
|
||||
actual['reservas_online'] = {**actual['reservas_online'], **bloque}
|
||||
env['ir.config_parameter'].sudo().set_param(
|
||||
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
||||
return get_settings(env)
|
||||
|
||||
@@ -200,6 +200,15 @@ class SkeenCita(models.Model):
|
||||
# WACRM
|
||||
wacrm_conversation_id = fields.Char(string='WACRM Conversation ID')
|
||||
|
||||
# Origen de la reserva: permite distinguir en la agenda las citas que
|
||||
# entraron por el portal público de reservas en línea. 'internal' es el
|
||||
# comportamiento de siempre (no cambia nada); 'public_portal' lo pone el
|
||||
# controlador de reservas públicas.
|
||||
booking_source = fields.Selection([
|
||||
('internal', 'Interno'),
|
||||
('public_portal', 'Portal en línea'),
|
||||
], string='Origen de la reserva', default='internal', index=True)
|
||||
|
||||
# Paquete terminado (para servicios con categoría 'paquete')
|
||||
package_finished = fields.Boolean(string='Paquete Terminado', default=False)
|
||||
package_finished_date = fields.Datetime(string='Fecha Término Paquete')
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
from . import main
|
||||
from . import frontend
|
||||
from . import public_booking
|
||||
from . import wacrm_proxy
|
||||
|
||||
@@ -1859,6 +1859,7 @@ class SkeenFrontendController(http.Controller):
|
||||
'doctor': cita.doctor_id.name if cita.doctor_id else None,
|
||||
'branch': cita.branch,
|
||||
'medium': cita.medium,
|
||||
'booking_source': cita.booking_source or 'internal',
|
||||
'notes': cita.notes or '',
|
||||
'is_first_visit': False,
|
||||
'package_finished': cita.package_finished,
|
||||
|
||||
670
odoo-addons/skeen_whatsapp/controllers/public_booking.py
Normal file
670
odoo-addons/skeen_whatsapp/controllers/public_booking.py
Normal file
@@ -0,0 +1,670 @@
|
||||
# -*- 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.<br/>'
|
||||
'IP: %s<br/>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)
|
||||
Reference in New Issue
Block a user