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:
2026-10-11 08:28:33 +00:00
parent 358b85afcd
commit add9b28188
14 changed files with 1859 additions and 4 deletions

View File

@@ -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)

View File

@@ -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')