bloque 2: cuenta del cliente (modulo skeen_portal_cliente: acceso con codigo por WhatsApp o enlace por correo, sesion propia separada del personal, mis citas con cancelar y reagendar, pagos, monedero, paquetes, membresias con pago del periodo, formularios, documentos y perfil; invitacion y control de sesiones desde el expediente); apagado por defecto
This commit is contained in:
@@ -362,6 +362,111 @@ def get_anticipo(env):
|
||||
"""Sub-bloque `anticipo` vigente (copia). Apagado por defecto."""
|
||||
return get_reservas_online(env)['anticipo']
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portal del cliente (cuenta de autoservicio del paciente).
|
||||
#
|
||||
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos` y
|
||||
# `reservas_online`. La regla de oro: con `activo=False` (default) NADA cambia
|
||||
# en la instancia — el portal público responde `activo=false`/404, el frontend
|
||||
# no muestra ninguna ruta ni enlace nuevo y las tablas propias quedan en cero.
|
||||
# Por eso el default es apagado.
|
||||
#
|
||||
# activo: enciende TODO el portal del cliente
|
||||
# metodo: otp_whatsapp | enlace_correo | ambos
|
||||
# requerir_correo_verificado: exige correo registrado para acceder
|
||||
# permitir_contrasena: permite fijar contraseña tras entrar
|
||||
# sesion_horas: vigencia de la sesión del cliente (horas)
|
||||
# permitir_reagendar: permite reagendar citas en línea
|
||||
# horas_minimas_cancelar: horas de antelación para cancelar en línea
|
||||
# mostrar_precios: muestra importes en citas y ventas
|
||||
# mostrar_recetas: muestra recetas en el portal
|
||||
# permitir_descargar_estado_cuenta: permite descargar el estado de cuenta
|
||||
# permitir_actualizar_datos: permite al cliente editar sus datos
|
||||
# ---------------------------------------------------------------------------
|
||||
PORTAL_CLIENTE_DEFAULT = {
|
||||
'activo': False,
|
||||
'metodo': 'otp_whatsapp',
|
||||
'requerir_correo_verificado': False,
|
||||
'permitir_contrasena': False,
|
||||
'sesion_horas': 24,
|
||||
'permitir_reagendar': True,
|
||||
'horas_minimas_cancelar': 24,
|
||||
'mostrar_precios': True,
|
||||
'mostrar_recetas': True,
|
||||
'permitir_descargar_estado_cuenta': True,
|
||||
'permitir_actualizar_datos': True,
|
||||
}
|
||||
|
||||
PORTAL_METODOS = ('otp_whatsapp', 'enlace_correo', 'ambos')
|
||||
PORTAL_SESION_HORAS_MIN, PORTAL_SESION_HORAS_MAX = 1, 720
|
||||
PORTAL_CANCELAR_HORAS_MAX = 168 # 1 semana
|
||||
PORTAL_BOOLEANOS = (
|
||||
'activo', 'requerir_correo_verificado', 'permitir_contrasena',
|
||||
'permitir_reagendar', 'mostrar_precios', 'mostrar_recetas',
|
||||
'permitir_descargar_estado_cuenta', 'permitir_actualizar_datos',
|
||||
)
|
||||
|
||||
|
||||
def normalizar_portal_cliente(raw):
|
||||
"""Bloque `portal_cliente` completo, con defaults. Tolerante: nunca lanza."""
|
||||
out = dict(PORTAL_CLIENTE_DEFAULT)
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for k in PORTAL_BOOLEANOS:
|
||||
out[k] = _bool(raw.get(k), PORTAL_CLIENTE_DEFAULT[k])
|
||||
metodo = str(raw.get('metodo') or '').strip()
|
||||
out['metodo'] = metodo if metodo in PORTAL_METODOS else PORTAL_CLIENTE_DEFAULT['metodo']
|
||||
out['sesion_horas'] = min(max(
|
||||
_int(raw.get('sesion_horas'), PORTAL_CLIENTE_DEFAULT['sesion_horas']),
|
||||
PORTAL_SESION_HORAS_MIN), PORTAL_SESION_HORAS_MAX)
|
||||
out['horas_minimas_cancelar'] = min(max(
|
||||
_int(raw.get('horas_minimas_cancelar'),
|
||||
PORTAL_CLIENTE_DEFAULT['horas_minimas_cancelar']),
|
||||
0), PORTAL_CANCELAR_HORAS_MAX)
|
||||
return out
|
||||
|
||||
|
||||
def validar_portal_cliente(raw):
|
||||
"""Valida el bloque `portal_cliente` de un PUT. Lanza ValueError si algo no
|
||||
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
|
||||
para que el merge anidado no reinicie lo que el PUT no tocó."""
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('El bloque del portal del cliente debe ser un objeto')
|
||||
out = {}
|
||||
for k in PORTAL_BOOLEANOS:
|
||||
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]
|
||||
if 'metodo' in raw:
|
||||
metodo = str(raw['metodo'] or '').strip()
|
||||
if metodo not in PORTAL_METODOS:
|
||||
raise ValueError("El método debe ser 'otp_whatsapp', 'enlace_correo' o 'ambos'")
|
||||
out['metodo'] = metodo
|
||||
if 'sesion_horas' in raw:
|
||||
if isinstance(raw['sesion_horas'], bool) or not isinstance(raw['sesion_horas'], int):
|
||||
raise ValueError('Las horas de sesión deben ser un número entero')
|
||||
if not (PORTAL_SESION_HORAS_MIN <= raw['sesion_horas'] <= PORTAL_SESION_HORAS_MAX):
|
||||
raise ValueError('Las horas de sesión deben estar entre %d y %d'
|
||||
% (PORTAL_SESION_HORAS_MIN, PORTAL_SESION_HORAS_MAX))
|
||||
out['sesion_horas'] = raw['sesion_horas']
|
||||
if 'horas_minimas_cancelar' in raw:
|
||||
if isinstance(raw['horas_minimas_cancelar'], bool) or \
|
||||
not isinstance(raw['horas_minimas_cancelar'], int):
|
||||
raise ValueError('Las horas mínimas para cancelar deben ser un número entero')
|
||||
if not (0 <= raw['horas_minimas_cancelar'] <= PORTAL_CANCELAR_HORAS_MAX):
|
||||
raise ValueError('Las horas mínimas para cancelar deben estar entre 0 y %d'
|
||||
% PORTAL_CANCELAR_HORAS_MAX)
|
||||
out['horas_minimas_cancelar'] = raw['horas_minimas_cancelar']
|
||||
return out
|
||||
|
||||
|
||||
def get_portal_cliente(env):
|
||||
"""Bloque del portal del cliente vigente (copia). Apagado por defecto."""
|
||||
return normalizar_portal_cliente(get_settings(env).get('portal_cliente'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recordatorios y notificaciones automáticas de citas.
|
||||
#
|
||||
@@ -427,6 +532,16 @@ NOTIFICACIONES_DEFAULT = {
|
||||
'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. '
|
||||
'Paga {monto} desde este enlace: {enlace}'
|
||||
),
|
||||
# Acceso a la cuenta del cliente (portal). El código y el enlace caducan:
|
||||
# se advierte de no compartirlos. Ver `skeen_portal_cliente`.
|
||||
'plantilla_codigo_acceso': (
|
||||
'Hola {nombre}, tu código para entrar a tu cuenta es {codigo}. Vence en '
|
||||
'{minutos} minutos. Por tu seguridad, no compartas este código con nadie.'
|
||||
),
|
||||
'plantilla_enlace_acceso': (
|
||||
'Hola {nombre}, entra a tu cuenta desde este enlace: {enlace}. '
|
||||
'No compartas este enlace con nadie.'
|
||||
),
|
||||
'whatsapp_numero': '',
|
||||
'email_remitente': '',
|
||||
}
|
||||
@@ -435,11 +550,14 @@ NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
|
||||
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
|
||||
'sucursal', 'telefono', 'enlace', 'formulario',
|
||||
# Variables del aviso de membresías.
|
||||
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
|
||||
'plan', 'membresia', 'monto', 'sesiones', 'periodo',
|
||||
# Variables del acceso a la cuenta del cliente (portal).
|
||||
'codigo', 'minutos')
|
||||
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
|
||||
'plantilla_cumpleanos', 'plantilla_post_visita',
|
||||
'plantilla_formulario', 'plantilla_renovacion_membresia',
|
||||
'plantilla_cobro_membresia')
|
||||
'plantilla_cobro_membresia', 'plantilla_codigo_acceso',
|
||||
'plantilla_enlace_acceso')
|
||||
NOTIF_HORAS_MAX = 168 # 1 semana
|
||||
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
|
||||
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
|
||||
@@ -1498,6 +1616,7 @@ def get_settings(env, aplicar_licencia=True):
|
||||
'compras': dict(COMPRAS_DEFAULT),
|
||||
'comisiones': dict(COMISIONES_DEFAULT),
|
||||
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
|
||||
'portal_cliente': normalizar_portal_cliente(None),
|
||||
}
|
||||
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
||||
if raw:
|
||||
@@ -1517,6 +1636,8 @@ def get_settings(env, aplicar_licencia=True):
|
||||
out['compras'] = normalizar_compras(guardado.get('compras'))
|
||||
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
|
||||
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
|
||||
out['portal_cliente'] = normalizar_portal_cliente(
|
||||
guardado.get('portal_cliente'))
|
||||
if aplicar_licencia:
|
||||
_aplicar_licencia(out)
|
||||
return out
|
||||
@@ -1570,6 +1691,11 @@ def set_settings(env, vals):
|
||||
if bloque is not None:
|
||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||
actual['comisiones'] = {**actual['comisiones'], **bloque}
|
||||
if 'portal_cliente' in vals:
|
||||
bloque = validar_portal_cliente(vals['portal_cliente'])
|
||||
if bloque is not None:
|
||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||
actual['portal_cliente'] = {**actual['portal_cliente'], **bloque}
|
||||
if 'licencia' in vals:
|
||||
bloque = validar_licencia(vals['licencia'])
|
||||
if bloque is not None:
|
||||
|
||||
Reference in New Issue
Block a user