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:
2026-10-12 00:33:32 +00:00
parent 6a6c2a928b
commit 1bd4cad6a0
26 changed files with 3981 additions and 5 deletions

View File

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