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')
|
||||
|
||||
Reference in New Issue
Block a user