Files
SKEEN-Proyecto/odoo-addons/skeen_citas/clinic_settings.py

2353 lines
102 KiB
Python

# -*- coding: utf-8 -*-
"""Ajustes de la clínica por instancia (white-label).
Vive en `skeen.clinic.settings` (JSON) y no tiene modelo propio. Antes estos
datos solo existían en el localStorage del navegador de cada equipo; ahora son
compartidos por toda la instancia y los consumen el frontend, la impresión de
tickets y las URLs de pago.
El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no
cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
"""
import base64
import hashlib
import hmac
import json
import os
import re
from datetime import date
from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings'
BRAND_KEY = 'skeen.clinic.brand'
BRAND_NOMBRE_DEFAULT = 'SKEEN Derma Experts'
HORA_APERTURA_DEFAULT = '08:00'
HORA_CIERRE_DEFAULT = '19:00'
ZONA_HORARIA_DEFAULT = 'America/Tijuana'
MONEDA_DEFAULT = 'MXN'
# Campos del contrato del endpoint.
CAMPOS = (
'nombre', 'telefono', 'email', 'direccion', 'web', 'rfc',
'horario_apertura', 'horario_cierre', 'zona_horaria', 'url_publica',
'moneda_base', 'nota_ticket',
)
# ---------------------------------------------------------------------------
# Impuestos (bloque anidado dentro del mismo JSON de ajustes).
#
# El impuesto es propio y simple: `account`/`l10n_mx` están desinstalados, así
# que no hay motor fiscal de Odoo. Con `activo=False` TODO el sistema se
# comporta como antes (sin cálculo, sin desglose, sin renglón en tickets); esa
# es la regla de oro y por eso los defaults son apagados y en cero.
#
# activo: enciende el cálculo en POS, tickets y reportes
# nombre: etiqueta del impuesto (p. ej. 'IVA')
# tasa_default: % que se aplica cuando el catálogo no fija una
# precios_incluyen_impuesto: el precio capturado YA trae impuesto; el
# subtotal se desglosa hacia atrás
# tasa_opciones: tasas ofrecidas en los formularios (%)
# ---------------------------------------------------------------------------
IMPUESTOS_DEFAULT = {
'activo': False,
'nombre': 'IVA',
'tasa_default': 0.0,
'precios_incluyen_impuesto': False,
'tasa_opciones': [0, 8, 16],
}
def _num(valor, por_defecto=0.0):
"""Número finito de un valor JSON, sin explotar con None/texto."""
try:
n = float(valor)
except (TypeError, ValueError):
return por_defecto
if n != n or n in (float('inf'), float('-inf')): # NaN / infinitos
return por_defecto
return n
def _bool(valor, por_defecto=False):
if isinstance(valor, bool):
return valor
if valor in ('1', 'true', 'True', 'si', 'sí', 1):
return True
if valor in ('0', 'false', 'False', 'no', 0):
return False
return por_defecto
def normalizar_impuestos(raw):
"""Bloque `impuestos` completo, con defaults, a partir de lo guardado.
Tolerante: nunca lanza. Lo inválido cae al default (una instancia sin
configurar debe verse EXACTAMENTE como hoy).
"""
out = dict(IMPUESTOS_DEFAULT)
if not isinstance(raw, dict):
return out
out['activo'] = _bool(raw.get('activo'), IMPUESTOS_DEFAULT['activo'])
out['precios_incluyen_impuesto'] = _bool(
raw.get('precios_incluyen_impuesto'),
IMPUESTOS_DEFAULT['precios_incluyen_impuesto'])
nombre = str(raw.get('nombre') or '').strip()
out['nombre'] = (nombre or IMPUESTOS_DEFAULT['nombre'])[:40]
out['tasa_default'] = min(max(_num(raw.get('tasa_default')), 0.0), 100.0)
opciones = raw.get('tasa_opciones')
if isinstance(opciones, list):
limpias = []
for o in opciones:
n = _num(o, None)
if n is None:
continue
n = min(max(n, 0.0), 100.0)
if n not in limpias:
limpias.append(n)
if limpias:
out['tasa_opciones'] = sorted(limpias)
return out
def validar_impuestos(raw):
"""Valida el bloque `impuestos` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400 en el controlador).
Devuelve SOLO las claves presentes (ya 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 impuestos debe ser un objeto')
out = {}
if 'activo' in raw:
if not isinstance(raw['activo'], bool):
raise ValueError('El campo activo de impuestos debe ser verdadero o falso')
out['activo'] = raw['activo']
if 'precios_incluyen_impuesto' in raw:
if not isinstance(raw['precios_incluyen_impuesto'], bool):
raise ValueError('precios_incluyen_impuesto debe ser verdadero o falso')
out['precios_incluyen_impuesto'] = raw['precios_incluyen_impuesto']
if 'nombre' in raw:
if not isinstance(raw['nombre'], str) or not raw['nombre'].strip():
raise ValueError('El nombre del impuesto no puede estar vacío')
if len(raw['nombre'].strip()) > 40:
raise ValueError('El nombre del impuesto es demasiado largo (máx. 40)')
out['nombre'] = raw['nombre'].strip()[:40]
if 'tasa_default' in raw:
n = _num(raw['tasa_default'], None)
if n is None:
raise ValueError('La tasa por defecto debe ser un número')
if not (0 <= n <= 100):
raise ValueError('La tasa por defecto debe estar entre 0 y 100')
out['tasa_default'] = n
if 'tasa_opciones' in raw:
opciones = raw['tasa_opciones']
if not isinstance(opciones, list) or not opciones:
raise ValueError('tasa_opciones debe ser una lista de números')
limpias = []
for o in opciones:
n = _num(o, None)
if n is None:
raise ValueError('Cada opción de tasa debe ser un número')
if not (0 <= n <= 100):
raise ValueError('Las opciones de tasa deben estar entre 0 y 100')
if n not in limpias:
limpias.append(n)
out['tasa_opciones'] = sorted(limpias)
return out
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': '',
'anticipo': {
'activo': False,
'tipo': 'porcentaje',
'valor': 0.0,
'moneda': 'MXN',
'horas_para_pagar': 24,
'reembolsable': True,
'texto_aviso': '',
# Extensión propia: sin credenciales de pasarela el enlace nace
# `simulado`; confirmarlo solo se permite si esta llave está encendida
# (nunca se simula un pago en producción con el default apagado).
'permitir_simulado': False,
},
}
# 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
ANTICIPO_HORAS_MAX = 720 # 30 días para pagar el anticipo
ANTICIPO_TIPOS = ('porcentaje', 'monto_fijo')
ANTICIPO_MONEDAS = ('MXN', 'USD')
ANTICIPO_TEXTO_MAX = 500
def _int(valor, por_defecto=0):
try:
return int(valor)
except (TypeError, ValueError):
return por_defecto
def normalizar_anticipo(raw):
"""Sub-bloque `anticipo` de reservas en línea, con defaults. Nunca lanza."""
out = dict(RESERVAS_ONLINE_DEFAULT['anticipo'])
if not isinstance(raw, dict):
return out
out['activo'] = _bool(raw.get('activo'), out['activo'])
out['reembolsable'] = _bool(raw.get('reembolsable'), out['reembolsable'])
out['permitir_simulado'] = _bool(raw.get('permitir_simulado'), out['permitir_simulado'])
tipo = str(raw.get('tipo') or '').strip()
out['tipo'] = tipo if tipo in ANTICIPO_TIPOS else out['tipo']
moneda = str(raw.get('moneda') or '').strip().upper()
out['moneda'] = moneda if moneda in ANTICIPO_MONEDAS else out['moneda']
out['valor'] = max(0.0, _num(raw.get('valor'), out['valor']))
out['horas_para_pagar'] = min(max(_int(raw.get('horas_para_pagar'),
out['horas_para_pagar']), 1), ANTICIPO_HORAS_MAX)
out['texto_aviso'] = str(raw.get('texto_aviso') or '')[:ANTICIPO_TEXTO_MAX]
return out
def validar_anticipo(raw):
"""Valida el sub-bloque `anticipo` de un PUT. Devuelve solo las claves
presentes y normalizadas (merge anidado)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de anticipo debe ser un objeto')
out = {}
for k in ('activo', 'reembolsable', 'permitir_simulado'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s del anticipo debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'tipo' in raw:
tipo = str(raw['tipo'] or '').strip()
if tipo not in ANTICIPO_TIPOS:
raise ValueError("El tipo de anticipo debe ser 'porcentaje' o 'monto_fijo'")
out['tipo'] = tipo
if 'moneda' in raw:
moneda = str(raw['moneda'] or '').strip().upper()
if moneda not in ANTICIPO_MONEDAS:
raise ValueError('La moneda del anticipo debe ser MXN o USD')
out['moneda'] = moneda
if 'valor' in raw:
n = _num(raw['valor'], None)
if n is None:
raise ValueError('El valor del anticipo debe ser un número')
if n < 0:
raise ValueError('El valor del anticipo no puede ser negativo')
if (out.get('tipo') or raw.get('tipo') or 'porcentaje') == 'porcentaje' and n > 100:
raise ValueError('El porcentaje del anticipo debe estar entre 0 y 100')
out['valor'] = n
if 'horas_para_pagar' in raw:
if isinstance(raw['horas_para_pagar'], bool) or not isinstance(raw['horas_para_pagar'], int):
raise ValueError('Las horas para pagar deben ser un número entero')
if not (1 <= raw['horas_para_pagar'] <= ANTICIPO_HORAS_MAX):
raise ValueError('Las horas para pagar deben estar entre 1 y %d' % ANTICIPO_HORAS_MAX)
out['horas_para_pagar'] = raw['horas_para_pagar']
if 'texto_aviso' in raw:
if not isinstance(raw['texto_aviso'], str):
raise ValueError('El texto de aviso debe ser texto')
if len(raw['texto_aviso']) > ANTICIPO_TEXTO_MAX:
raise ValueError('El texto de aviso es demasiado largo (máx. %d)' % ANTICIPO_TEXTO_MAX)
out['texto_aviso'] = raw['texto_aviso'].strip()
return out
def normalizar_reservas_online(raw):
"""Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza."""
out = dict(RESERVAS_ONLINE_DEFAULT)
out['anticipo'] = normalizar_anticipo(
raw.get('anticipo') if isinstance(raw, dict) else None)
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]
if 'anticipo' in raw:
sub = validar_anticipo(raw['anticipo'])
if sub is not None:
out['anticipo'] = sub
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'))
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.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos` y
# `reservas_online`. La regla de oro: con `activo=False` NADA se envía ni se
# encola (cero cambios de comportamiento y cero filas nuevas); por eso el
# default es apagado. `modo_prueba=True` (default) es la segunda llave: las
# notificaciones se registran como "simuladas" y no se llama a ningún proveedor
# externo, de modo que se puede probar sin credenciales reales.
#
# activo: enciende recordatorios y notificaciones
# modo_prueba: no envía nada real; solo registra "simulada"
# canal: whatsapp | email | ambos
# recordatorio_horas_antes: ventanas (0-168 h) en que se avisa antes de la cita
# confirmacion_al_crear: avisar al paciente al reservar en línea
# cumpleanos: felicitación el día del cumpleaños
# hora_envio_cumpleanos: hora (HH:MM) a partir de la cual se felicita
# post_visita_horas: horas tras la visita para el mensaje de seguimiento
# recuperacion_no_show: mensaje a quien no se presentó
# plantilla_*: textos con variables {nombre} {cita} {servicio}...
# whatsapp_numero: número de la clínica que firma los mensajes
# email_remitente: remitente de los correos
# ---------------------------------------------------------------------------
NOTIFICACIONES_DEFAULT = {
'activo': False,
'modo_prueba': True,
'canal': 'whatsapp',
'recordatorio_horas_antes': [24, 2],
'confirmacion_al_crear': False,
'cumpleanos': False,
'hora_envio_cumpleanos': '09:00',
'post_visita_horas': 0,
'recuperacion_no_show': False,
'plantilla_recordatorio': (
'Hola {nombre}, te recordamos tu {cita} de {servicio} el {fecha} a las '
'{hora} con {doctor} en {sucursal}. Responde CONFIRMAR o CANCELAR.'
),
'plantilla_confirmacion': (
'Hola {nombre}, tu {cita} de {servicio} quedó registrada para el {fecha} '
'a las {hora} con {doctor} en {sucursal}. Te esperamos.'
),
'plantilla_cumpleanos': (
'¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un '
'gran día. Te esperamos pronto.'
),
'plantilla_post_visita': (
'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes '
'dudas o quieres agendar de nuevo, llámanos al {telefono}.'
),
'plantilla_formulario': (
'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. '
'Ábrelo desde tu celular aquí: {enlace}'
),
# Aviso de renovación de una membresía (bloque `membresias`). Usa variables
# propias ({plan} {monto} {sesiones} {periodo}) que el motor recibe como
# contexto extra al encolar; ver `skeen.membresia.service`.
'plantilla_renovacion_membresia': (
'Hola {nombre}, tu plan {plan} se renueva el {fecha} ({periodo}) por '
'{monto}. Tienes {sesiones} sesiones este periodo. {sucursal}.'
),
# Aviso con el ENLACE DE PAGO del periodo (cobro automático de membresías).
'plantilla_cobro_membresia': (
'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': '',
}
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',
# 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_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
# Variables reconocidas dentro de una plantilla: {nombre}, {cita}, ...
_VARIABLE_RE = re.compile(r'\{([a-zA-Z_]+)\}')
def horas_recordatorio_validas(valor, por_defecto=None):
"""Lista de horas de recordatorio (enteros 0-168, sin repetir, ordenada)."""
if valor is None:
return list(por_defecto or NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
if not isinstance(valor, (list, tuple)):
return list(por_defecto or NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
limpias = []
for v in valor:
try:
n = int(v)
except (TypeError, ValueError):
continue
if isinstance(v, bool) or not (0 <= n <= NOTIF_HORAS_MAX):
continue
if n not in limpias:
limpias.append(n)
return sorted(limpias) if limpias else list(por_defecto or [])
def _hhmm_valida(valor, por_defecto='09:00'):
"""Normaliza 'HH:MM'. Devuelve el default si el formato no es válido."""
partes = str(valor or '').strip().split(':')
if len(partes) != 2:
return por_defecto
try:
h, m = int(partes[0]), int(partes[1])
except (TypeError, ValueError):
return por_defecto
if not (0 <= h <= 23 and 0 <= m <= 59):
return por_defecto
return '%02d:%02d' % (h, m)
def variables_de_plantilla(texto):
"""Variables {x} usadas en una plantilla (sin repetir, en orden)."""
usadas = []
for m in _VARIABLE_RE.finditer(str(texto or '')):
if m.group(1) not in usadas:
usadas.append(m.group(1))
return usadas
def normalizar_notificaciones(raw):
"""Bloque `notificaciones` completo, con defaults. Tolerante: nunca lanza."""
out = dict(NOTIFICACIONES_DEFAULT)
out['recordatorio_horas_antes'] = list(NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'modo_prueba', 'confirmacion_al_crear', 'cumpleanos',
'recuperacion_no_show'):
out[k] = _bool(raw.get(k), NOTIFICACIONES_DEFAULT[k])
canal = str(raw.get('canal') or '').strip().lower()
out['canal'] = canal if canal in NOTIF_CANALES else NOTIFICACIONES_DEFAULT['canal']
out['recordatorio_horas_antes'] = horas_recordatorio_validas(
raw.get('recordatorio_horas_antes'))
out['hora_envio_cumpleanos'] = _hhmm_valida(
raw.get('hora_envio_cumpleanos'), NOTIFICACIONES_DEFAULT['hora_envio_cumpleanos'])
out['post_visita_horas'] = min(max(_int(raw.get('post_visita_horas'),
NOTIFICACIONES_DEFAULT['post_visita_horas']),
0), NOTIF_HORAS_MAX)
for k in NOTIF_PLANTILLAS:
texto = raw.get(k)
if texto is None:
continue
out[k] = str(texto)[:NOTIF_PLANTILLA_MAX]
out['whatsapp_numero'] = str(raw.get('whatsapp_numero') or '').strip()[:NOTIF_TEXTO_MAX]
out['email_remitente'] = str(raw.get('email_remitente') or '').strip()[:NOTIF_TEXTO_MAX]
return out
def validar_notificaciones(raw):
"""Valida el bloque `notificaciones` 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 de notificaciones debe ser un objeto')
out = {}
for k in ('activo', 'modo_prueba', 'confirmacion_al_crear', 'cumpleanos',
'recuperacion_no_show'):
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 'canal' in raw:
canal = str(raw['canal'] or '').strip().lower()
if canal not in NOTIF_CANALES:
raise ValueError('El canal debe ser whatsapp, email o ambos')
out['canal'] = canal
if 'recordatorio_horas_antes' in raw:
valor = raw['recordatorio_horas_antes']
if not isinstance(valor, list) or not valor:
raise ValueError('Las horas de recordatorio deben ser una lista')
horas = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada ventana de recordatorio debe ser un número entero de horas')
if not (0 <= v <= NOTIF_HORAS_MAX):
raise ValueError('Las horas de recordatorio deben estar entre 0 y %d' % NOTIF_HORAS_MAX)
if v not in horas:
horas.append(v)
out['recordatorio_horas_antes'] = sorted(horas)
if 'hora_envio_cumpleanos' in raw:
partes = str(raw['hora_envio_cumpleanos'] or '').strip().split(':')
valido = False
if len(partes) == 2:
try:
h, m = int(partes[0]), int(partes[1])
valido = 0 <= h <= 23 and 0 <= m <= 59
except (TypeError, ValueError):
valido = False
if not valido:
raise ValueError('La hora de cumpleaños debe tener el formato HH:MM')
out['hora_envio_cumpleanos'] = _hhmm_valida(raw['hora_envio_cumpleanos'])
if 'post_visita_horas' in raw:
if isinstance(raw['post_visita_horas'], bool) or not isinstance(raw['post_visita_horas'], int):
raise ValueError('Las horas de post-visita deben ser un número entero')
if not (0 <= raw['post_visita_horas'] <= NOTIF_HORAS_MAX):
raise ValueError('Las horas de post-visita deben estar entre 0 y %d' % NOTIF_HORAS_MAX)
out['post_visita_horas'] = raw['post_visita_horas']
for k in NOTIF_PLANTILLAS:
if k not in raw:
continue
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k]) > NOTIF_PLANTILLA_MAX:
raise ValueError('La plantilla es demasiado larga (máx. %d caracteres)' % NOTIF_PLANTILLA_MAX)
desconocidas = [v for v in variables_de_plantilla(raw[k]) if v not in NOTIF_VARIABLES]
if desconocidas:
raise ValueError('Variable no permitida en la plantilla: {%s}. Permitidas: %s'
% (desconocidas[0], ', '.join('{%s}' % v for v in NOTIF_VARIABLES)))
out[k] = raw[k]
for k in ('whatsapp_numero', 'email_remitente'):
if k in raw:
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k].strip()) > NOTIF_TEXTO_MAX:
raise ValueError('El campo %s es demasiado largo (máx. %d)' % (k, NOTIF_TEXTO_MAX))
out[k] = raw[k].strip()
remitente = out.get('email_remitente')
if remitente and '@' not in remitente:
raise ValueError('El correo remitente no es válido')
return out
def get_notificaciones(env):
"""Bloque de notificaciones vigente (copia). Fuente única para el motor."""
return normalizar_notificaciones(get_settings(env).get('notificaciones'))
# ---------------------------------------------------------------------------
# Membresías y suscripciones.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Membresías no aparece en el menú, el POS no ofrece sesiones de membresía, no
# corre el cron y no se genera ningún cargo ni aviso. Por eso el default es
# apagado.
#
# activo: enciende TODO el módulo de membresías
# dias_aviso_renovacion: días antes del cobro en que se avisa
# permitir_congelamiento: permite congelar membresías
# dias_max_congelamiento_anio: tope de días congelados por año
# renovacion_automatica_default: valor por defecto al dar de alta
# descuento_porcentaje_default: descuento por defecto en servicios no incluidos
# permitir_pausa_pago: permite pausar la membresía por falta de pago
# generar_cargo_al_renovar: el cron genera el adeudo del periodo
# dias_gracia: días tras el vencimiento antes de marcarla vencida
# cobro_automatico: {activo, dias_antes, metodo, reintentos_dias,
# cancelar_si_no_paga, dias_gracia, permitir_simulado}
# ---------------------------------------------------------------------------
MEMBRESIAS_DEFAULT = {
'activo': False,
'dias_aviso_renovacion': [7, 1],
'permitir_congelamiento': True,
'dias_max_congelamiento_anio': 30,
'renovacion_automatica_default': False,
'descuento_porcentaje_default': 0,
'permitir_pausa_pago': False,
'generar_cargo_al_renovar': True,
'dias_gracia': 5,
'cobro_automatico': {
'activo': False,
'dias_antes': 3,
'metodo': 'enlace',
'reintentos_dias': [1, 3, 7],
'cancelar_si_no_paga': False,
'dias_gracia': 5,
# Extensión propia: permite confirmar enlaces en modo simulado (sin
# pasarela). Apagado por defecto: nunca se cobra de mentiras.
'permitir_simulado': False,
},
}
MEMBRESIAS_DIAS_MAX = 365 # tope del límite de congelamiento por año
MEMBRESIAS_AVISO_MAX = 365 # tope de días de aviso de renovación
COBRO_METODOS = ('enlace', 'tarjeta_guardada')
COBRO_DIAS_ANTES_MAX = 365
COBRO_REINTENTO_MAX = 365
def _dias_lista(valor, por_defecto=None):
"""Lista de días (enteros 0-365, sin repetir, ordenada). Tolerante."""
default = list(por_defecto or MEMBRESIAS_DEFAULT['cobro_automatico']['reintentos_dias'])
if not isinstance(valor, (list, tuple)):
return default
limpios = []
for v in valor:
if isinstance(v, bool):
continue
try:
n = int(v)
except (TypeError, ValueError):
continue
if 0 <= n <= COBRO_REINTENTO_MAX and n not in limpios:
limpios.append(n)
return sorted(limpios) if limpios else default
def normalizar_cobro_automatico(raw):
"""Sub-bloque `cobro_automatico` de membresías, con defaults. Nunca lanza."""
out = dict(MEMBRESIAS_DEFAULT['cobro_automatico'])
out['reintentos_dias'] = list(MEMBRESIAS_DEFAULT['cobro_automatico']['reintentos_dias'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'cancelar_si_no_paga', 'permitir_simulado'):
out[k] = _bool(raw.get(k), out[k])
metodo = str(raw.get('metodo') or '').strip()
out['metodo'] = metodo if metodo in COBRO_METODOS else out['metodo']
out['dias_antes'] = min(max(_int(raw.get('dias_antes'), out['dias_antes']),
0), COBRO_DIAS_ANTES_MAX)
out['dias_gracia'] = min(max(_int(raw.get('dias_gracia'), out['dias_gracia']), 0), 365)
out['reintentos_dias'] = _dias_lista(raw.get('reintentos_dias'),
out['reintentos_dias'])
return out
def validar_cobro_automatico(raw):
"""Valida el sub-bloque `cobro_automatico` de un PUT (merge anidado)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de cobro automático debe ser un objeto')
out = {}
for k in ('activo', 'cancelar_si_no_paga', 'permitir_simulado'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s del cobro automático debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'metodo' in raw:
metodo = str(raw['metodo'] or '').strip()
if metodo not in COBRO_METODOS:
raise ValueError("El método debe ser 'enlace' o 'tarjeta_guardada'")
out['metodo'] = metodo
if 'dias_antes' in raw:
if isinstance(raw['dias_antes'], bool) or not isinstance(raw['dias_antes'], int):
raise ValueError('Los días antes del cobro deben ser un número entero')
if not (0 <= raw['dias_antes'] <= COBRO_DIAS_ANTES_MAX):
raise ValueError('Los días antes deben estar entre 0 y %d' % COBRO_DIAS_ANTES_MAX)
out['dias_antes'] = raw['dias_antes']
if 'dias_gracia' in raw:
if isinstance(raw['dias_gracia'], bool) or not isinstance(raw['dias_gracia'], int):
raise ValueError('Los días de gracia del cobro deben ser un número entero')
if not (0 <= raw['dias_gracia'] <= 365):
raise ValueError('Los días de gracia deben estar entre 0 y 365')
out['dias_gracia'] = raw['dias_gracia']
if 'reintentos_dias' in raw:
valor = raw['reintentos_dias']
if not isinstance(valor, list):
raise ValueError('Los días de reintento deben ser una lista')
dias = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada día de reintento debe ser un número entero')
if not (0 <= v <= COBRO_REINTENTO_MAX):
raise ValueError('Los días de reintento deben estar entre 0 y %d' % COBRO_REINTENTO_MAX)
if v not in dias:
dias.append(v)
out['reintentos_dias'] = sorted(dias)
return out
def normalizar_membresias(raw):
"""Bloque `membresias` completo, con defaults. Tolerante: nunca lanza."""
out = dict(MEMBRESIAS_DEFAULT)
out['dias_aviso_renovacion'] = list(MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
out['cobro_automatico'] = normalizar_cobro_automatico(
raw.get('cobro_automatico') if isinstance(raw, dict) else None)
if not isinstance(raw, dict):
return out
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
'permitir_pausa_pago', 'generar_cargo_al_renovar'):
out[k] = _bool(raw.get(k), MEMBRESIAS_DEFAULT[k])
out['dias_aviso_renovacion'] = horas_recordatorio_validas(
raw.get('dias_aviso_renovacion'), MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
out['dias_max_congelamiento_anio'] = min(max(_int(
raw.get('dias_max_congelamiento_anio'),
MEMBRESIAS_DEFAULT['dias_max_congelamiento_anio']), 0), MEMBRESIAS_DIAS_MAX)
out['descuento_porcentaje_default'] = min(max(_num(
raw.get('descuento_porcentaje_default'),
MEMBRESIAS_DEFAULT['descuento_porcentaje_default']), 0.0), 100.0)
out['dias_gracia'] = min(max(_int(raw.get('dias_gracia'),
MEMBRESIAS_DEFAULT['dias_gracia']), 0), 365)
return out
def validar_membresias(raw):
"""Valida el bloque `membresias` 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 de membresías debe ser un objeto')
out = {}
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
'permitir_pausa_pago', 'generar_cargo_al_renovar'):
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 'dias_aviso_renovacion' in raw:
valor = raw['dias_aviso_renovacion']
if not isinstance(valor, list) or not valor:
raise ValueError('Los días de aviso de renovación deben ser una lista')
dias = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada día de aviso debe ser un número entero')
if not (0 <= v <= MEMBRESIAS_AVISO_MAX):
raise ValueError('Los días de aviso deben estar entre 0 y %d' % MEMBRESIAS_AVISO_MAX)
if v not in dias:
dias.append(v)
out['dias_aviso_renovacion'] = sorted(set(dias))
if 'dias_max_congelamiento_anio' in raw:
if isinstance(raw['dias_max_congelamiento_anio'], bool) or \
not isinstance(raw['dias_max_congelamiento_anio'], int):
raise ValueError('El máximo de días de congelamiento debe ser un número entero')
if not (0 <= raw['dias_max_congelamiento_anio'] <= MEMBRESIAS_DIAS_MAX):
raise ValueError('El máximo de días de congelamiento debe estar entre 0 y %d'
% MEMBRESIAS_DIAS_MAX)
out['dias_max_congelamiento_anio'] = raw['dias_max_congelamiento_anio']
if 'descuento_porcentaje_default' in raw:
n = _num(raw['descuento_porcentaje_default'], None)
if n is None:
raise ValueError('El descuento por defecto debe ser un número')
if not (0 <= n <= 100):
raise ValueError('El descuento por defecto debe estar entre 0 y 100')
out['descuento_porcentaje_default'] = n
if 'dias_gracia' in raw:
if isinstance(raw['dias_gracia'], bool) or not isinstance(raw['dias_gracia'], int):
raise ValueError('Los días de gracia deben ser un número entero')
if not (0 <= raw['dias_gracia'] <= 365):
raise ValueError('Los días de gracia deben estar entre 0 y 365')
out['dias_gracia'] = raw['dias_gracia']
if 'cobro_automatico' in raw:
sub = validar_cobro_automatico(raw['cobro_automatico'])
if sub is not None:
out['cobro_automatico'] = sub
return out
def get_membresias(env):
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
return normalizar_membresias(get_settings(env).get('membresias'))
def get_cobro_automatico(env):
"""Sub-bloque `cobro_automatico` de membresías (copia). Apagado por defecto."""
return get_membresias(env)['cobro_automatico']
# ---------------------------------------------------------------------------
# Recursos físicos de la agenda (cabinas, salas, consultorios, equipos).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la agenda — no aparece la
# sección en Configuración ni el selector en el modal de cita, la agenda se ve
# igual que hoy y no corre ninguna validación de solape de recursos. Por eso el
# default es apagado.
#
# activo: enciende todo el módulo de recursos
# validar_solape: impide dos citas/bloqueos en el mismo recurso a la
# misma hora (solo aplica si activo)
# obligatorio_al_agendar: exige elegir al menos un recurso al crear la cita
# permitir_multiples: permite asignar más de un recurso a una cita
# mostrar_en_agenda: muestra el recurso (chips/color) en las tarjetas
# filtrar_como_doctores: ofrece el filtro por recurso junto al de doctores
# ---------------------------------------------------------------------------
RECURSOS_DEFAULT = {
'activo': False,
'validar_solape': True,
'obligatorio_al_agendar': False,
'permitir_multiples': True,
'mostrar_en_agenda': True,
'filtrar_como_doctores': True,
}
RECURSOS_BOOLEANOS = tuple(RECURSOS_DEFAULT)
def normalizar_recursos(raw):
"""Bloque `recursos` completo, con defaults. Tolerante: nunca lanza."""
out = dict(RECURSOS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in RECURSOS_BOOLEANOS:
out[k] = _bool(raw.get(k), RECURSOS_DEFAULT[k])
return out
def validar_recursos(raw):
"""Valida el bloque `recursos` 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 de recursos debe ser un objeto')
out = {}
for k in RECURSOS_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]
return out
def get_recursos(env):
"""Bloque de recursos vigente (copia). Fuente única para modelos y API."""
return normalizar_recursos(get_settings(env).get('recursos'))
# ---------------------------------------------------------------------------
# Compras y proveedores.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Compras no aparece en el menú, el inventario y el POS se ven idénticos, no
# corre ningún cron y las rutas del módulo responden 403. Por eso el default es
# apagado.
#
# activo: enciende TODO el módulo de compras
# actualizar_costo_promedio: al recibir, recalcula el costo promedio ponderado
# permitir_recepcion_parcial: permite recibir menos de lo pedido y recibir después
# requiere_autorizacion: las órdenes nacen en borrador y exigen enviarse
# dias_credito_default: días de crédito por defecto de un proveedor nuevo
# margen_minimo_alerta: margen (%) bajo el cual el reporte avisa
# permitir_compras_sin_orden: permite registrar una recepción sin orden de compra
# ---------------------------------------------------------------------------
COMPRAS_DEFAULT = {
'activo': False,
'actualizar_costo_promedio': True,
'permitir_recepcion_parcial': True,
'requiere_autorizacion': False,
'dias_credito_default': 0,
'margen_minimo_alerta': 0,
'permitir_compras_sin_orden': False,
}
COMPRAS_DIAS_CREDITO_MAX = 365
COMPRAS_MARGEN_MAX = 1000 # % (permite margen negativo / muy alto sin tope real)
def normalizar_compras(raw):
"""Bloque `compras` completo, con defaults. Tolerante: nunca lanza."""
out = dict(COMPRAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'actualizar_costo_promedio', 'permitir_recepcion_parcial',
'requiere_autorizacion', 'permitir_compras_sin_orden'):
out[k] = _bool(raw.get(k), COMPRAS_DEFAULT[k])
out['dias_credito_default'] = min(max(_int(
raw.get('dias_credito_default'), COMPRAS_DEFAULT['dias_credito_default']),
0), COMPRAS_DIAS_CREDITO_MAX)
out['margen_minimo_alerta'] = min(max(_num(
raw.get('margen_minimo_alerta'), COMPRAS_DEFAULT['margen_minimo_alerta']),
-COMPRAS_MARGEN_MAX), COMPRAS_MARGEN_MAX)
return out
def validar_compras(raw):
"""Valida el bloque `compras` 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 de compras debe ser un objeto')
out = {}
for k in ('activo', 'actualizar_costo_promedio', 'permitir_recepcion_parcial',
'requiere_autorizacion', 'permitir_compras_sin_orden'):
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 'dias_credito_default' in raw:
if isinstance(raw['dias_credito_default'], bool) or \
not isinstance(raw['dias_credito_default'], int):
raise ValueError('Los días de crédito deben ser un número entero')
if not (0 <= raw['dias_credito_default'] <= COMPRAS_DIAS_CREDITO_MAX):
raise ValueError('Los días de crédito deben estar entre 0 y %d'
% COMPRAS_DIAS_CREDITO_MAX)
out['dias_credito_default'] = raw['dias_credito_default']
if 'margen_minimo_alerta' in raw:
n = _num(raw['margen_minimo_alerta'], None)
if n is None:
raise ValueError('El margen mínimo de alerta debe ser un número')
if not (-COMPRAS_MARGEN_MAX <= n <= COMPRAS_MARGEN_MAX):
raise ValueError('El margen mínimo de alerta está fuera de rango')
out['margen_minimo_alerta'] = n
return out
def get_compras(env):
"""Bloque de compras vigente (copia). Fuente única para modelos y API."""
return normalizar_compras(get_settings(env).get('compras'))
# ---------------------------------------------------------------------------
# Comisiones por reglas y liquidación.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — el reporte de
# comisiones de siempre (`/reports/comisiones` y `/reports/commissions`) sigue
# devolviendo el mismo JSON y los mismos números (porcentaje plano por empleado),
# no aparece ninguna sección nueva en el menú y no se ejecuta el motor de reglas.
#
# activo: enciende el motor de reglas y las liquidaciones
# base: 'cobrado' (sobre lo pagado) | 'vendido' (subtotal)
# incluir_impuestos: la base suma el impuesto de la línea
# neto_de_devoluciones: resta la parte devuelta de la venta
# comisionar_membresias: comisiona las ventas de membresía
# comisionar_paquetes: comisiona los paquetes
# comisionar_productos: comisiona los artículos de inventario
# permitir_ajustes_manuales: habilita sumar/restar ajustes en la liquidación
# dia_corte: día del mes de corte sugerido (1-28)
# redondeo: decimales a los que se redondea la comisión
# pago_minimo: total mínimo a liquidar (por debajo no es pagable)
# ---------------------------------------------------------------------------
COMISIONES_DEFAULT = {
'activo': False,
'base': 'cobrado',
'incluir_impuestos': False,
'neto_de_devoluciones': True,
'comisionar_membresias': True,
'comisionar_paquetes': True,
'comisionar_productos': True,
'permitir_ajustes_manuales': True,
'dia_corte': 1,
'redondeo': 2,
'pago_minimo': 0,
}
COMISIONES_BASES = ('cobrado', 'vendido')
COMISIONES_REDONDEO_MAX = 6
def normalizar_comisiones(raw):
"""Bloque `comisiones` completo, con defaults. Tolerante: nunca lanza."""
out = dict(COMISIONES_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'incluir_impuestos', 'neto_de_devoluciones',
'comisionar_membresias', 'comisionar_paquetes', 'comisionar_productos',
'permitir_ajustes_manuales'):
out[k] = _bool(raw.get(k), COMISIONES_DEFAULT[k])
base = str(raw.get('base') or '').strip().lower()
out['base'] = base if base in COMISIONES_BASES else COMISIONES_DEFAULT['base']
out['dia_corte'] = min(max(_int(raw.get('dia_corte'), COMISIONES_DEFAULT['dia_corte']), 1), 28)
out['redondeo'] = min(max(_int(raw.get('redondeo'), COMISIONES_DEFAULT['redondeo']),
0), COMISIONES_REDONDEO_MAX)
out['pago_minimo'] = max(0.0, _num(raw.get('pago_minimo'), COMISIONES_DEFAULT['pago_minimo']))
return out
def validar_comisiones(raw):
"""Valida el bloque `comisiones` 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 de comisiones debe ser un objeto')
out = {}
for k in ('activo', 'incluir_impuestos', 'neto_de_devoluciones',
'comisionar_membresias', 'comisionar_paquetes', 'comisionar_productos',
'permitir_ajustes_manuales'):
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 'base' in raw:
base = str(raw['base'] or '').strip().lower()
if base not in COMISIONES_BASES:
raise ValueError("La base debe ser 'cobrado' o 'vendido'")
out['base'] = base
if 'dia_corte' in raw:
if isinstance(raw['dia_corte'], bool) or not isinstance(raw['dia_corte'], int):
raise ValueError('El día de corte debe ser un número entero')
if not (1 <= raw['dia_corte'] <= 28):
raise ValueError('El día de corte debe estar entre 1 y 28')
out['dia_corte'] = raw['dia_corte']
if 'redondeo' in raw:
if isinstance(raw['redondeo'], bool) or not isinstance(raw['redondeo'], int):
raise ValueError('El redondeo debe ser un número entero de decimales')
if not (0 <= raw['redondeo'] <= COMISIONES_REDONDEO_MAX):
raise ValueError('El redondeo debe estar entre 0 y %d' % COMISIONES_REDONDEO_MAX)
out['redondeo'] = raw['redondeo']
if 'pago_minimo' in raw:
n = _num(raw['pago_minimo'], None)
if n is None:
raise ValueError('El pago mínimo debe ser un número')
if n < 0:
raise ValueError('El pago mínimo no puede ser negativo')
out['pago_minimo'] = n
return out
def get_comisiones(env):
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
return normalizar_comisiones(get_settings(env).get('comisiones'))
# ---------------------------------------------------------------------------
# Programa de lealtad (puntos del monedero) y niveles VIP.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con los valores por defecto (`modo='clasico'`) NADA cambia — se acumula
# 1 punto por cada $10 pagados, 1 punto = $1, el canje se topa al 50% y no hay
# niveles ni multiplicadores. `modo='configurable'` usa los valores del bloque.
#
# activo: enciende el programa de puntos
# modo: clasico | configurable
# puntos_por_unidad: puntos por cada peso (0.1 = 1 pt por $10)
# valor_punto: pesos que vale un punto al canjear
# moneda_base: MXN | USD (solo informativo / etiqueta)
# canje_max_pct: % máximo del total pagable con puntos
# expira_meses: 0 = no expiran; >0 expira lo no usado más antiguo
# acumular_sobre: pagado (monto cobrado) | vendido (monto vendido)
# aplicar_a: servicios | productos | membresias | paquetes
# redondear: abajo (truncar) | normal (redondeo comercial)
# niveles: [{codigo, nombre, gasto_minimo, ventana_meses,
# descuento_pct, factor_puntos, vigencia_meses, color}]
# multiplicadores: [{nombre, alcance, valor, factor, fecha_desde,
# fecha_hasta, activo}]
#
# PRECEDENCIA de multiplicadores (gana la MÁS específica; a igual
# especificidad, la última de la lista): servicio > articulo > categoria >
# nivel > sucursal > rango_fechas > dia_semana.
#
# NIVELES: el nivel se decide por el gasto acumulado en su ventana (por defecto
# 12 meses). Un cliente sube de nivel al cerrar la venta que lo alcanza (o por
# el proceso diario). NO baja de inmediato: el nivel conserva su vigencia
# (`vigencia_meses`) y solo al vencer se recalcula a la baja. Una asignación
# manual (con fecha de revisión) no se sobreescribe sola hasta esa fecha.
# ---------------------------------------------------------------------------
LEALTAD_DEFAULT = {
'activo': True,
'modo': 'clasico',
'puntos_por_unidad': 0.1,
'valor_punto': 1.0,
'moneda_base': 'MXN',
'canje_max_pct': 50,
'expira_meses': 0,
'acumular_sobre': 'pagado',
'aplicar_a': ['servicios', 'productos', 'membresias', 'paquetes'],
'redondear': 'abajo',
'niveles': [],
'multiplicadores': [],
}
LEALTAD_MODOS = ('clasico', 'configurable')
LEALTAD_ACUMULAR = ('pagado', 'vendido')
LEALTAD_APLICAR = ('servicios', 'productos', 'membresias', 'paquetes')
LEALTAD_REDONDEAR = ('abajo', 'normal')
LEALTAD_ALCANCES = ('servicio', 'articulo', 'categoria', 'nivel', 'sucursal',
'rango_fechas', 'dia_semana')
LEALTAD_MONEDAS = ('MXN', 'USD')
LEALTAD_EXPIRA_MAX = 120 # meses
LEALTAD_VENTANA_MAX = 120 # meses (ventana de gasto de un nivel)
LEALTAD_NIVELES_MAX = 20
LEALTAD_MULT_MAX = 100
def _fecha_iso(valor):
"""'YYYY-MM-DD' válida o ''."""
texto = str(valor or '').strip()[:10]
if not texto:
return ''
try:
date.fromisoformat(texto)
except ValueError:
return ''
return texto
def normalizar_niveles(raw):
"""Lista de niveles VIP normalizada (ordenada por gasto_minimo desc)."""
out = []
if not isinstance(raw, (list, tuple)):
return out
for item in raw:
if not isinstance(item, dict):
continue
codigo = str(item.get('codigo') or '').strip()[:40]
nombre = str(item.get('nombre') or '').strip()[:80]
if not codigo and not nombre:
continue
if not codigo:
codigo = nombre.lower().replace(' ', '_')[:40]
out.append({
'codigo': codigo,
'nombre': nombre or codigo,
'gasto_minimo': max(0.0, _num(item.get('gasto_minimo'), 0.0)),
'ventana_meses': min(max(_int(item.get('ventana_meses'), 12), 1), LEALTAD_VENTANA_MAX),
'descuento_pct': min(max(_num(item.get('descuento_pct'), 0.0), 0.0), 100.0),
'factor_puntos': max(0.0, _num(item.get('factor_puntos'), 1.0)),
'vigencia_meses': min(max(_int(item.get('vigencia_meses'), 12), 0), LEALTAD_VENTANA_MAX),
'color': str(item.get('color') or '').strip()[:20],
})
out.sort(key=lambda n: -n['gasto_minimo'])
return out[:LEALTAD_NIVELES_MAX]
def normalizar_multiplicadores(raw):
"""Lista de reglas multiplicadoras normalizada (tolerante)."""
out = []
if not isinstance(raw, (list, tuple)):
return out
for item in raw:
if not isinstance(item, dict):
continue
alcance = str(item.get('alcance') or '').strip().lower()
if alcance not in LEALTAD_ALCANCES:
continue
out.append({
'nombre': str(item.get('nombre') or '').strip()[:80],
'alcance': alcance,
'valor': str(item.get('valor') or '').strip()[:120],
'factor': max(0.0, _num(item.get('factor'), 1.0)),
'fecha_desde': _fecha_iso(item.get('fecha_desde')),
'fecha_hasta': _fecha_iso(item.get('fecha_hasta')),
'activo': _bool(item.get('activo'), True),
})
return out[:LEALTAD_MULT_MAX]
def normalizar_lealtad(raw):
"""Bloque `lealtad` completo, con defaults. Tolerante: nunca lanza."""
out = dict(LEALTAD_DEFAULT)
out['aplicar_a'] = list(LEALTAD_DEFAULT['aplicar_a'])
if not isinstance(raw, dict):
return out
out['activo'] = _bool(raw.get('activo'), LEALTAD_DEFAULT['activo'])
modo = str(raw.get('modo') or '').strip().lower()
out['modo'] = modo if modo in LEALTAD_MODOS else LEALTAD_DEFAULT['modo']
acu = str(raw.get('acumular_sobre') or '').strip().lower()
out['acumular_sobre'] = acu if acu in LEALTAD_ACUMULAR else LEALTAD_DEFAULT['acumular_sobre']
red = str(raw.get('redondear') or '').strip().lower()
out['redondear'] = red if red in LEALTAD_REDONDEAR else LEALTAD_DEFAULT['redondear']
mon = str(raw.get('moneda_base') or '').strip().upper()
out['moneda_base'] = mon if mon in LEALTAD_MONEDAS else LEALTAD_DEFAULT['moneda_base']
out['puntos_por_unidad'] = max(0.0, _num(raw.get('puntos_por_unidad'),
LEALTAD_DEFAULT['puntos_por_unidad']))
out['valor_punto'] = max(0.0, _num(raw.get('valor_punto'), LEALTAD_DEFAULT['valor_punto']))
out['canje_max_pct'] = min(max(_num(raw.get('canje_max_pct'),
LEALTAD_DEFAULT['canje_max_pct']), 0.0), 100.0)
out['expira_meses'] = min(max(_int(raw.get('expira_meses'),
LEALTAD_DEFAULT['expira_meses']), 0), LEALTAD_EXPIRA_MAX)
aplicar = raw.get('aplicar_a')
if isinstance(aplicar, (list, tuple)):
limpio = [a for a in (str(x).strip().lower() for x in aplicar)
if a in LEALTAD_APLICAR]
out['aplicar_a'] = list(dict.fromkeys(limpio))
out['niveles'] = normalizar_niveles(raw.get('niveles'))
out['multiplicadores'] = normalizar_multiplicadores(raw.get('multiplicadores'))
if out['modo'] == 'clasico':
# El modo clásico reproduce EXACTAMENTE el comportamiento de siempre:
# se congelan los parámetros numéricos y se ignoran niveles y
# multiplicadores (que se conservan para el editor, pero no aplican).
out['puntos_por_unidad'] = LEALTAD_DEFAULT['puntos_por_unidad']
out['valor_punto'] = LEALTAD_DEFAULT['valor_punto']
out['canje_max_pct'] = LEALTAD_DEFAULT['canje_max_pct']
out['acumular_sobre'] = LEALTAD_DEFAULT['acumular_sobre']
out['aplicar_a'] = list(LEALTAD_DEFAULT['aplicar_a'])
out['redondear'] = LEALTAD_DEFAULT['redondear']
return out
def validar_lealtad(raw):
"""Valida el bloque `lealtad` de un PUT (devuelve SOLO lo presente)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de lealtad debe ser un objeto')
out = {}
for k in ('activo',):
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 'modo' in raw:
modo = str(raw['modo'] or '').strip().lower()
if modo not in LEALTAD_MODOS:
raise ValueError("El modo debe ser 'clasico' o 'configurable'")
out['modo'] = modo
if 'acumular_sobre' in raw:
acu = str(raw['acumular_sobre'] or '').strip().lower()
if acu not in LEALTAD_ACUMULAR:
raise ValueError("acumular_sobre debe ser 'pagado' o 'vendido'")
out['acumular_sobre'] = acu
if 'redondear' in raw:
red = str(raw['redondear'] or '').strip().lower()
if red not in LEALTAD_REDONDEAR:
raise ValueError("El redondeo debe ser 'abajo' o 'normal'")
out['redondear'] = red
if 'moneda_base' in raw:
mon = str(raw['moneda_base'] or '').strip().upper()
if mon not in LEALTAD_MONEDAS:
raise ValueError('La moneda base debe ser MXN o USD')
out['moneda_base'] = mon
for k, lo, hi in (('puntos_por_unidad', 0.0, 10000.0),
('valor_punto', 0.0, 100000.0),
('canje_max_pct', 0.0, 100.0)):
if k in raw:
n = _num(raw[k], None)
if n is None or not (lo <= n <= hi):
raise ValueError('El campo %s debe ser un número entre %s y %s' % (k, lo, hi))
out[k] = n
if 'expira_meses' in raw:
if isinstance(raw['expira_meses'], bool) or not isinstance(raw['expira_meses'], int):
raise ValueError('Los meses de expiración deben ser un número entero')
if not (0 <= raw['expira_meses'] <= LEALTAD_EXPIRA_MAX):
raise ValueError('Los meses de expiración deben estar entre 0 y %d' % LEALTAD_EXPIRA_MAX)
out['expira_meses'] = raw['expira_meses']
if 'aplicar_a' in raw:
valor = raw['aplicar_a']
if not isinstance(valor, list):
raise ValueError('aplicar_a debe ser una lista')
limpio = []
for a in valor:
a = str(a or '').strip().lower()
if a not in LEALTAD_APLICAR:
raise ValueError('Valor desconocido en aplicar_a: %s' % a)
if a not in limpio:
limpio.append(a)
out['aplicar_a'] = limpio
if 'niveles' in raw:
if not isinstance(raw['niveles'], list):
raise ValueError('Los niveles deben ser una lista')
for n in raw['niveles']:
if not isinstance(n, dict):
raise ValueError('Cada nivel debe ser un objeto')
out['niveles'] = normalizar_niveles(raw['niveles'])
if 'multiplicadores' in raw:
if not isinstance(raw['multiplicadores'], list):
raise ValueError('Los multiplicadores deben ser una lista')
for m in raw['multiplicadores']:
if not isinstance(m, dict):
raise ValueError('Cada multiplicador debe ser un objeto')
if str(m.get('alcance') or '').strip().lower() not in LEALTAD_ALCANCES:
raise ValueError('Alcance desconocido en un multiplicador')
out['multiplicadores'] = normalizar_multiplicadores(raw['multiplicadores'])
return out
def get_lealtad(env):
"""Bloque de lealtad vigente (copia). Fuente única para modelos y API."""
return normalizar_lealtad(get_settings(env).get('lealtad'))
# ---------------------------------------------------------------------------
# Propinas en el punto de venta y reparto entre el personal.
#
# Bloque anidado del mismo JSON de ajustes. La regla de oro: con `activo=False`
# (default) el punto de venta se ve y se comporta EXACTAMENTE igual que hoy —
# no aparece ningún campo de propina, la venta no guarda propina y el total es
# el de siempre.
#
# activo: enciende la propina en el POS
# sugerencias_pct: porcentajes sugeridos [10, 15, 20]
# permitir_personalizado: permite capturar un monto libre
# permitir_sin_propina: permite cobrar sin propina
# reparto: ninguno | equitativo | por_participacion
# incluir_en_liquidacion_comisiones: suma la propina a la liquidación
# cuenta_propinas: etiqueta de la cuenta destino (informativa)
# ---------------------------------------------------------------------------
PROPINAS_DEFAULT = {
'activo': False,
'sugerencias_pct': [10, 15, 20],
'permitir_personalizado': True,
'permitir_sin_propina': True,
'reparto': 'ninguno',
'incluir_en_liquidacion_comisiones': False,
'cuenta_propinas': '',
}
PROPINAS_REPARTO = ('ninguno', 'equitativo', 'por_participacion')
PROPINAS_SUGERENCIA_MAX = 100
PROPINAS_CUENTA_MAX = 80
def normalizar_propinas(raw):
"""Bloque `propinas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(PROPINAS_DEFAULT)
out['sugerencias_pct'] = list(PROPINAS_DEFAULT['sugerencias_pct'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'permitir_personalizado', 'permitir_sin_propina',
'incluir_en_liquidacion_comisiones'):
out[k] = _bool(raw.get(k), PROPINAS_DEFAULT[k])
rep = str(raw.get('reparto') or '').strip().lower()
out['reparto'] = rep if rep in PROPINAS_REPARTO else PROPINAS_DEFAULT['reparto']
sugerencias = raw.get('sugerencias_pct')
if isinstance(sugerencias, (list, tuple)):
limpias = []
for s in sugerencias:
n = _num(s, None)
if n is None:
continue
n = min(max(n, 0.0), PROPINAS_SUGERENCIA_MAX)
if n not in limpias:
limpias.append(n)
out['sugerencias_pct'] = sorted(limpias)
out['cuenta_propinas'] = str(raw.get('cuenta_propinas') or '').strip()[:PROPINAS_CUENTA_MAX]
return out
def validar_propinas(raw):
"""Valida el bloque `propinas` de un PUT (devuelve SOLO lo presente)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de propinas debe ser un objeto')
out = {}
for k in ('activo', 'permitir_personalizado', 'permitir_sin_propina',
'incluir_en_liquidacion_comisiones'):
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 'reparto' in raw:
rep = str(raw['reparto'] or '').strip().lower()
if rep not in PROPINAS_REPARTO:
raise ValueError("El reparto debe ser 'ninguno', 'equitativo' o 'por_participacion'")
out['reparto'] = rep
if 'sugerencias_pct' in raw:
valor = raw['sugerencias_pct']
if not isinstance(valor, list):
raise ValueError('Las sugerencias de propina deben ser una lista')
limpias = []
for s in valor:
n = _num(s, None)
if n is None:
raise ValueError('Cada sugerencia de propina debe ser un número')
if not (0 <= n <= PROPINAS_SUGERENCIA_MAX):
raise ValueError('Las sugerencias deben estar entre 0 y %d' % PROPINAS_SUGERENCIA_MAX)
if n not in limpias:
limpias.append(n)
out['sugerencias_pct'] = sorted(limpias)
if 'cuenta_propinas' in raw:
if not isinstance(raw['cuenta_propinas'], str):
raise ValueError('La cuenta de propinas debe ser texto')
if len(raw['cuenta_propinas'].strip()) > PROPINAS_CUENTA_MAX:
raise ValueError('El nombre de la cuenta es demasiado largo (máx. %d)' % PROPINAS_CUENTA_MAX)
out['cuenta_propinas'] = raw['cuenta_propinas'].strip()
return out
def get_propinas(env):
"""Bloque de propinas vigente (copia). Apagado por defecto."""
return normalizar_propinas(get_settings(env).get('propinas'))
# ---------------------------------------------------------------------------
# Campañas por segmentos (marketing sobre la base de pacientes).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Campañas no aparece en el menú, no corre ningún cron, no se encola ni se
# envía nada y las tablas propias quedan en cero.
#
# activo: enciende TODO el módulo de campañas
# envios_por_minuto: tope de mensajes que el motor manda por minuto
# pausar_tras_errores: errores seguidos que pausan la campaña sola
# ventana_atribucion_dias: días tras el envío en que una cita se atribuye
# exigir_consentimiento: nunca escribir a quien no autorizó marketing
# hora_envio: hora (HH:MM) de los envíos recurrentes
# ---------------------------------------------------------------------------
CAMPANAS_DEFAULT = {
'activo': False,
'envios_por_minuto': 20,
'pausar_tras_errores': 5,
'ventana_atribucion_dias': 7,
'exigir_consentimiento': True,
'hora_envio': '10:00',
}
CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX = 1, 600
CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX = 1, 100
CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX = 1, 90
def normalizar_campanas(raw):
"""Bloque `campanas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(CAMPANAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'exigir_consentimiento'):
out[k] = _bool(raw.get(k), CAMPANAS_DEFAULT[k])
out['envios_por_minuto'] = min(max(_int(
raw.get('envios_por_minuto'), CAMPANAS_DEFAULT['envios_por_minuto']),
CAMPANAS_ENVIOS_MIN), CAMPANAS_ENVIOS_MAX)
out['pausar_tras_errores'] = min(max(_int(
raw.get('pausar_tras_errores'), CAMPANAS_DEFAULT['pausar_tras_errores']),
CAMPANAS_ERRORES_MIN), CAMPANAS_ERRORES_MAX)
out['ventana_atribucion_dias'] = min(max(_int(
raw.get('ventana_atribucion_dias'), CAMPANAS_DEFAULT['ventana_atribucion_dias']),
CAMPANAS_VENTANA_MIN), CAMPANAS_VENTANA_MAX)
out['hora_envio'] = _hhmm_valida(raw.get('hora_envio'), CAMPANAS_DEFAULT['hora_envio'])
return out
def validar_campanas(raw):
"""Valida el bloque `campanas` de un PUT. Lanza ValueError si algo no cuadra.
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de campañas debe ser un objeto')
out = {}
for k in ('activo', 'exigir_consentimiento'):
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, lo, hi in (('envios_por_minuto', CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX),
('pausar_tras_errores', CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX),
('ventana_atribucion_dias', CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX)):
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]
if 'hora_envio' in raw:
partes = str(raw['hora_envio'] or '').strip().split(':')
valido = False
if len(partes) == 2:
try:
h, m = int(partes[0]), int(partes[1])
valido = 0 <= h <= 23 and 0 <= m <= 59
except (TypeError, ValueError):
valido = False
if not valido:
raise ValueError('La hora de envío debe tener el formato HH:MM')
out['hora_envio'] = _hhmm_valida(raw['hora_envio'])
return out
def get_campanas(env):
"""Bloque de campañas vigente (copia). Apagado por defecto."""
return normalizar_campanas(get_settings(env).get('campanas'))
# ---------------------------------------------------------------------------
# Encuestas de salida y satisfacción (NPS).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — no se encola
# ni se envía ninguna encuesta tras completar una cita y no se genera ninguna
# respuesta nueva.
#
# activo: enciende el envío automático de la encuesta al salir
# enviar_tras_horas: horas después de completarse la cita en que se envía
# plantilla_id: plantilla de formulario (tipo encuesta) a enviar
# canal: whatsapp | email | ambos
# recordatorio_horas: horas para reenviar si no responde (0 = sin recordatorio)
# solo_primera_visita: envía solo a quien tiene una única visita
# agradecimiento: mensaje de gracias que ve el paciente al terminar
# ---------------------------------------------------------------------------
ENCUESTAS_DEFAULT = {
'activo': False,
'enviar_tras_horas': 2,
'plantilla_id': None,
'canal': 'whatsapp',
'recordatorio_horas': 48,
'solo_primera_visita': False,
'agradecimiento': '',
}
ENCUESTAS_CANALES = ('whatsapp', 'email', 'ambos')
ENCUESTAS_HORAS_MAX = 720 # 30 días
ENCUESTAS_RECORDATORIO_MAX = 720
ENCUESTAS_AGRADECIMIENTO_MAX = 500
def normalizar_encuestas(raw):
"""Bloque `encuestas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(ENCUESTAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'solo_primera_visita'):
out[k] = _bool(raw.get(k), ENCUESTAS_DEFAULT[k])
out['enviar_tras_horas'] = min(max(_int(
raw.get('enviar_tras_horas'), ENCUESTAS_DEFAULT['enviar_tras_horas']),
0), ENCUESTAS_HORAS_MAX)
out['recordatorio_horas'] = min(max(_int(
raw.get('recordatorio_horas'), ENCUESTAS_DEFAULT['recordatorio_horas']),
0), ENCUESTAS_RECORDATORIO_MAX)
canal = str(raw.get('canal') or '').strip().lower()
out['canal'] = canal if canal in ENCUESTAS_CANALES else ENCUESTAS_DEFAULT['canal']
pid = raw.get('plantilla_id')
if pid in (None, '', False, 0, '0'):
out['plantilla_id'] = None
else:
n = _int(pid, 0)
out['plantilla_id'] = n if n > 0 else None
out['agradecimiento'] = str(raw.get('agradecimiento') or '')[:ENCUESTAS_AGRADECIMIENTO_MAX]
return out
def validar_encuestas(raw):
"""Valida el bloque `encuestas` de un PUT. Lanza ValueError si algo no cuadra.
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de encuestas debe ser un objeto')
out = {}
for k in ('activo', 'solo_primera_visita'):
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, hi in (('enviar_tras_horas', ENCUESTAS_HORAS_MAX),
('recordatorio_horas', ENCUESTAS_RECORDATORIO_MAX)):
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 (0 <= raw[k] <= hi):
raise ValueError('El campo %s debe estar entre 0 y %d' % (k, hi))
out[k] = raw[k]
if 'canal' in raw:
canal = str(raw['canal'] or '').strip().lower()
if canal not in ENCUESTAS_CANALES:
raise ValueError('El canal debe ser whatsapp, email o ambos')
out['canal'] = canal
if 'plantilla_id' in raw:
pid = raw['plantilla_id']
if pid in (None, '', False, 0):
out['plantilla_id'] = None
else:
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
raise ValueError('La plantilla de encuesta debe ser un identificador válido')
out['plantilla_id'] = pid
if 'agradecimiento' in raw:
if not isinstance(raw['agradecimiento'], str):
raise ValueError('El agradecimiento debe ser texto')
if len(raw['agradecimiento']) > ENCUESTAS_AGRADECIMIENTO_MAX:
raise ValueError('El agradecimiento es demasiado largo (máx. %d)'
% ENCUESTAS_AGRADECIMIENTO_MAX)
out['agradecimiento'] = raw['agradecimiento'].strip()
return out
def get_encuestas(env):
"""Bloque de encuestas vigente (copia). Apagado por defecto."""
return normalizar_encuestas(get_settings(env).get('encuestas'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: SIN licencia aplicada (`clave=''`) NADA cambia en la instancia. Además,
# con `modo='aviso'` (el default) la licencia SOLO advierte: no esconde menús,
# no apaga módulos y no impone límites. Solo con `modo='bloqueo'` y una clave
# válida se aplican los módulos incluidos y los límites.
#
# plan: basico | profesional | enterprise | custom (informativo + default de módulos)
# modo: aviso (default) | bloqueo
# cliente: nombre del cliente al que se licenció
# vence: 'YYYY-MM-DD' (vacío = sin vencimiento)
# clave: clave firmada por el vendedor (ver generar_licencia.py)
# modulos: módulos incluidos; VACÍO = todos los módulos disponibles
# limites: topes de uso; 0 = sin límite (default -> nada cambia)
#
# La licencia NUNCA bloquea lo indispensable (login, agenda, configuración):
# solo aplica a los módulos "gateables" y a los límites indicados.
# ---------------------------------------------------------------------------
LICENCIA_DEFAULT = {
'plan': '',
'modo': 'aviso',
'cliente': '',
'vence': '',
'clave': '',
'modulos': [],
'limites': {
'usuarios_max': 0,
'sucursales_max': 0,
'citas_mes_max': 0,
'almacenamiento_mb': 0,
},
}
LICENCIA_PLANES = ('basico', 'profesional', 'enterprise', 'custom')
LICENCIA_MODOS = ('aviso', 'bloqueo')
LIMITE_KEYS = ('usuarios_max', 'sucursales_max', 'citas_mes_max', 'almacenamiento_mb')
# Módulos que una licencia puede incluir/excluir. Los "gateables" son los que
# tienen un bloque de configuración con `activo` (se apagan solos) o una guarda
# explícita (whatsapp, formularios).
MODULOS_GATEABLES = (
'whatsapp', 'notificaciones', 'reservas_online', 'recursos',
'membresias', 'compras', 'comisiones', 'formularios',
)
# Módulos siempre incluidos (no se pueden quitar: son la base de la clínica).
MODULOS_BASE = ('agenda', 'pacientes', 'pos', 'inventario', 'reportes')
MODULO_LABEL = {
'agenda': 'Agenda', 'pacientes': 'Pacientes y expediente', 'pos': 'Punto de venta',
'inventario': 'Inventario', 'reportes': 'Reportes', 'whatsapp': 'WhatsApp y WACRM',
'notificaciones': 'Notificaciones', 'reservas_online': 'Reservas en línea',
'recursos': 'Recursos de la agenda', 'membresias': 'Membresías',
'compras': 'Compras y proveedores', 'comisiones': 'Comisiones',
'formularios': 'Formularios digitales',
}
# Módulo gateable -> bloque de clinic-settings cuyo `activo` se apaga al quedar
# fuera de la licencia. whatsapp y formularios no tienen bloque: se guardan por
# controlador (ver skeen_plataforma y los controladores de cada módulo).
BLOQUE_POR_MODULO = {
'notificaciones': 'notificaciones',
'reservas_online': 'reservas_online',
'recursos': 'recursos',
'membresias': 'membresias',
'compras': 'compras',
'comisiones': 'comisiones',
}
# Planes predefinidos (tabla de producto). `modulos` = lista explícita; el CLI
# de licencias la usa como default cuando no se pasan --modulos.
PLANES = {
'basico': {
'label': 'Básico',
'modulos': list(MODULOS_BASE),
},
'profesional': {
'label': 'Profesional',
'modulos': list(MODULOS_BASE) + [
'whatsapp', 'notificaciones', 'reservas_online', 'recursos',
],
},
'enterprise': {
'label': 'Enterprise',
'modulos': list(MODULOS_BASE) + list(MODULOS_GATEABLES),
},
'custom': {
'label': 'A medida',
'modulos': [],
},
}
def modulos_de_plan(plan):
"""Módulos por defecto de un plan (lista; vacía = todos)."""
return list((PLANES.get(plan) or {}).get('modulos') or [])
def normalizar_licencia(raw):
"""Bloque `licencia` completo, con defaults. Tolerante: nunca lanza."""
out = dict(LICENCIA_DEFAULT)
out['limites'] = dict(LICENCIA_DEFAULT['limites'])
out['modulos'] = []
if not isinstance(raw, dict):
return out
plan = str(raw.get('plan') or '').strip().lower()
out['plan'] = plan if plan in LICENCIA_PLANES else ''
modo = str(raw.get('modo') or '').strip().lower()
out['modo'] = modo if modo in LICENCIA_MODOS else LICENCIA_DEFAULT['modo']
out['cliente'] = str(raw.get('cliente') or '').strip()[:120]
out['vence'] = str(raw.get('vence') or '').strip()[:10]
out['clave'] = str(raw.get('clave') or '').strip()
modulos = raw.get('modulos')
if isinstance(modulos, (list, tuple)):
limpios = []
for m in modulos:
m = str(m or '').strip().lower()
if m and m in (MODULOS_BASE + MODULOS_GATEABLES) and m not in limpios:
limpios.append(m)
out['modulos'] = limpios
limites = raw.get('limites')
if isinstance(limites, dict):
for k in LIMITE_KEYS:
out['limites'][k] = max(0, _int(limites.get(k), 0))
return out
def validar_licencia(raw):
"""Valida el bloque `licencia` de un PUT directo. Devuelve SOLO las claves
presentes para que el merge no reinicie lo que el PUT no tocó.
La verificación de la FIRMA de la clave NO vive aquí (depende de la clave
del vendedor): la hace el controlador antes de guardar."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de licencia debe ser un objeto')
out = {}
if 'plan' in raw:
plan = str(raw['plan'] or '').strip().lower()
if plan and plan not in LICENCIA_PLANES:
raise ValueError('El plan debe ser básico, profesional, enterprise o custom')
out['plan'] = plan
if 'modo' in raw:
modo = str(raw['modo'] or '').strip().lower()
if modo not in LICENCIA_MODOS:
raise ValueError("El modo debe ser 'aviso' o 'bloqueo'")
out['modo'] = modo
for k, limite in (('cliente', 120), ('vence', 10)):
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' % k)
out[k] = raw[k].strip()
if 'clave' in raw:
if not isinstance(raw['clave'], str):
raise ValueError('La clave de licencia debe ser texto')
out['clave'] = raw['clave'].strip()
if 'modulos' in raw:
valor = raw['modulos']
if not isinstance(valor, list):
raise ValueError('Los módulos deben ser una lista')
limpios = []
for m in valor:
m = str(m or '').strip().lower()
if m not in (MODULOS_BASE + MODULOS_GATEABLES):
raise ValueError('Módulo desconocido: %s' % m)
if m not in limpios:
limpios.append(m)
out['modulos'] = limpios
if 'limites' in raw:
valor = raw['limites']
if not isinstance(valor, dict):
raise ValueError('Los límites deben ser un objeto')
limpios = {}
for k, v in valor.items():
if k not in LIMITE_KEYS:
raise ValueError('Límite desconocido: %s' % k)
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Los límites deben ser números enteros')
if v < 0:
raise ValueError('Los límites no pueden ser negativos')
limpios[k] = v
out['limites'] = limpios
return out
def modulos_licencia(licencia):
"""Módulos permitidos por una licencia. None = todos (sin restricción)."""
if not isinstance(licencia, dict):
return None
if not licencia.get('clave'):
return None
modulos = licencia.get('modulos') or []
if not modulos:
return None
return set(modulos)
def licencia_bloqueo_activo(licencia):
"""True si la licencia está en modo bloqueo y tiene clave aplicada."""
return bool(isinstance(licencia, dict) and licencia.get('clave')
and licencia.get('modo') == 'bloqueo')
def modulo_bloqueado(licencia, clave):
"""True si el módulo gateable `clave` queda fuera de la licencia."""
if clave not in MODULOS_GATEABLES:
return False
if not licencia_bloqueo_activo(licencia):
return False
permitidos = modulos_licencia(licencia)
if permitidos is None:
return False
return clave not in permitidos
def licencia_de(env):
"""Bloque de licencia vigente (copia)."""
return get_settings(env).get('licencia') or dict(LICENCIA_DEFAULT)
def modulo_habilitado(env, clave):
"""True si el módulo `clave` está habilitado (sin licencia = todo habilitado)."""
return not modulo_bloqueado(licencia_de(env), clave)
def limite_licencia(env, clave):
"""Tope configurado para `clave` (0 = sin límite)."""
return int((licencia_de(env).get('limites') or {}).get(clave) or 0)
def _aplicar_licencia(out):
"""Apaga los bloques `activo` de los módulos fuera de la licencia.
Se aplica SOLO con modo=bloqueo y clave válida; así el resto del sistema
(endpoints, menús y validaciones que leen `activo`) reacciona solo. Con
`aviso` o sin licencia, `out` queda intacto."""
licencia = out.get('licencia') or {}
if not licencia_bloqueo_activo(licencia):
return
for modulo, bloque in BLOQUE_POR_MODULO.items():
if modulo_bloqueado(licencia, modulo) and isinstance(out.get(bloque), dict):
out[bloque]['activo'] = False
# ---------------------------------------------------------------------------
# Clave de licencia firmada (HMAC-SHA256 con la clave del vendedor).
#
# Formato compacto:
# SKN1.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor))>
#
# `payload_json` usa claves ordenadas y separadores mínimos, para que el CLI del
# vendedor (infra/licencias/generar_licencia.py, Python stdlib) genere
# exactamente los mismos bytes. La clave del vendedor se configura en la
# instancia por:
# 1) ir.config_parameter 'skeen.licencia.vendor_key'
# 2) variable de entorno SKEEN_LICENCIA_VENDOR_KEY
# 3) archivo /root/.skeen-licencias/vendor.key (permisos 600)
# Sin clave de vendedor configurada, ninguna licencia se puede validar (y sin
# licencia aplicada el sistema se comporta como siempre).
# ---------------------------------------------------------------------------
LICENCIA_PREFIJO = 'SKN1'
LICENCIA_VENDOR_KEY_ICP = 'skeen.licencia.vendor_key'
LICENCIA_VENDOR_KEY_ENV = 'SKEEN_LICENCIA_VENDOR_KEY'
LICENCIA_VENDOR_KEY_FILE = '/root/.skeen-licencias/vendor.key'
def vendor_key(env=None):
"""Clave del vendedor vigente (ICP -> entorno -> archivo). '' si no hay."""
key = ''
if env is not None:
try:
key = env['ir.config_parameter'].sudo().get_param(LICENCIA_VENDOR_KEY_ICP) or ''
except Exception:
key = ''
if not key:
key = os.environ.get(LICENCIA_VENDOR_KEY_ENV) or ''
if not key:
try:
with open(LICENCIA_VENDOR_KEY_FILE, 'r', encoding='utf-8') as fh:
key = (fh.read() or '').strip()
except OSError:
key = ''
return key
def _b64url(raw):
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _b64url_decode(texto):
return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4))
def _payload_canonico(payload):
return json.dumps(payload, sort_keys=True, separators=(',', ':'),
ensure_ascii=False).encode('utf-8')
def firmar_licencia(payload, vendor):
"""Devuelve la clave de licencia firmada (mismo algoritmos que el CLI)."""
cuerpo = _b64url(_payload_canonico(payload))
firma = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
hashlib.sha256).digest()
return '%s.%s.%s' % (LICENCIA_PREFIJO, cuerpo, _b64url(firma))
def verificar_clave_licencia(env, clave):
"""Verifica firma y vigencia. Devuelve (payload, error).
`payload` es el dict de la licencia si la clave es válida; `error` es un
mensaje claro de texto si no lo es (uno de los dos es None)."""
clave = (clave or '').strip()
if not clave:
return None, 'No se recibió ninguna clave de licencia.'
partes = clave.split('.')
if len(partes) != 3 or partes[0] != LICENCIA_PREFIJO:
return None, 'La clave de licencia no tiene el formato esperado.'
vendor = vendor_key(env)
if not vendor:
return None, ('No hay clave de vendedor configurada en esta instancia '
'(skeen.licencia.vendor_key).')
_, cuerpo, firma = partes
esperada = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
hashlib.sha256).digest()
try:
recibida = _b64url_decode(firma)
except Exception:
return None, 'La firma de la clave de licencia está corrupta.'
if not hmac.compare_digest(esperada, recibida):
return None, 'La firma de la clave no es válida (clave manipulada).'
try:
payload = json.loads(_b64url_decode(cuerpo).decode('utf-8'))
except Exception:
return None, 'El contenido de la clave de licencia está corrupto.'
if not isinstance(payload, dict):
return None, 'El contenido de la clave de licencia no es válido.'
vence = str(payload.get('vence') or '').strip()
if vence:
try:
d = date.fromisoformat(vence)
except ValueError:
return None, 'La fecha de vencimiento de la clave no es válida.'
if d < date.today():
return None, 'Esta licencia venció el %s.' % vence
return payload, None
def licencia_desde_payload(payload, clave, modo=None):
"""Bloque `licencia` a guardar a partir de una clave ya validada."""
payload = payload or {}
plan = str(payload.get('plan') or '').strip().lower()
modulos = payload.get('modulos')
if not isinstance(modulos, list):
modulos = modulos_de_plan(plan) if plan in PLANES else []
return normalizar_licencia({
'plan': plan if plan in LICENCIA_PLANES else 'custom',
'modo': modo or 'bloqueo',
'cliente': payload.get('cliente') or '',
'vence': payload.get('vence') or '',
'clave': clave,
'modulos': modulos,
'limites': payload.get('limites') or {},
})
def dias_para_vencer(licencia):
"""Días restantes hasta el vencimiento (None si no hay fecha)."""
vence = str((licencia or {}).get('vence') or '').strip()
if not vence:
return None
try:
return (date.fromisoformat(vence) - date.today()).days
except ValueError:
return None
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
def brand_dict(env):
"""Brand vigente (subconjunto suficiente), sin defaults de logos."""
raw = env['ir.config_parameter'].sudo().get_param(BRAND_KEY)
if raw:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
return guardado
return {}
def brand_nombre(env):
nombre = brand_dict(env).get('nombre')
nombre = str(nombre).strip() if nombre is not None else ''
return nombre or BRAND_NOMBRE_DEFAULT
def get_settings(env, aplicar_licencia=True):
"""Objeto completo de ajustes, con defaults si no hay nada guardado.
`aplicar_licencia=False` devuelve lo GUARDADO sin apagar por licencia (lo
necesita set_settings: si persistiera el estado ya apagado por la licencia,
quitar la licencia no restauraría los módulos)."""
out = {
'nombre': brand_nombre(env),
'telefono': '',
'email': '',
'direccion': '',
'web': '',
'rfc': '',
'horario_apertura': HORA_APERTURA_DEFAULT,
'horario_cierre': HORA_CIERRE_DEFAULT,
'zona_horaria': ZONA_HORARIA_DEFAULT,
'url_publica': '',
'moneda_base': MONEDA_DEFAULT,
'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': normalizar_reservas_online(None),
'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT),
'lealtad': normalizar_lealtad(None),
'propinas': dict(PROPINAS_DEFAULT),
'campanas': dict(CAMPANAS_DEFAULT),
'encuestas': dict(ENCUESTAS_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:
try:
guardado = json.loads(raw)
except (ValueError, TypeError):
guardado = None
if isinstance(guardado, dict):
for k in CAMPOS:
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'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
out['campanas'] = normalizar_campanas(guardado.get('campanas'))
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
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
def set_settings(env, vals):
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
if not isinstance(vals, dict):
raise ValueError('Los ajustes deben ser un objeto')
actual = get_settings(env, aplicar_licencia=False)
for k in CAMPOS:
if k in vals and vals[k] is not None:
actual[k] = str(vals[k]).strip()
if 'impuestos' in vals:
bloque = validar_impuestos(vals['impuestos'])
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:
anterior = actual['reservas_online']
nuevo = {**anterior, **bloque}
if isinstance(bloque.get('anticipo'), dict):
nuevo['anticipo'] = {**anterior.get('anticipo', {}), **bloque['anticipo']}
actual['reservas_online'] = nuevo
if 'notificaciones' in vals:
bloque = validar_notificaciones(vals['notificaciones'])
if bloque is not None:
actual['notificaciones'] = {**actual['notificaciones'], **bloque}
if 'membresias' in vals:
bloque = validar_membresias(vals['membresias'])
if bloque is not None:
anterior = actual['membresias']
nuevo = {**anterior, **bloque}
if isinstance(bloque.get('cobro_automatico'), dict):
nuevo['cobro_automatico'] = {
**anterior.get('cobro_automatico', {}), **bloque['cobro_automatico']}
actual['membresias'] = nuevo
if 'recursos' in vals:
bloque = validar_recursos(vals['recursos'])
if bloque is not None:
actual['recursos'] = {**actual['recursos'], **bloque}
if 'compras' in vals:
bloque = validar_compras(vals['compras'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['compras'] = {**actual['compras'], **bloque}
if 'comisiones' in vals:
bloque = validar_comisiones(vals['comisiones'])
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 'lealtad' in vals:
bloque = validar_lealtad(vals['lealtad'])
if bloque is not None:
# Merge anidado: un PUT parcial no borra el resto. Las listas de
# niveles y multiplicadores sí se reemplazan completas.
actual['lealtad'] = {**actual['lealtad'], **bloque}
if 'propinas' in vals:
bloque = validar_propinas(vals['propinas'])
if bloque is not None:
actual['propinas'] = {**actual['propinas'], **bloque}
if 'campanas' in vals:
bloque = validar_campanas(vals['campanas'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['campanas'] = {**actual['campanas'], **bloque}
if 'encuestas' in vals:
bloque = validar_encuestas(vals['encuestas'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['encuestas'] = {**actual['encuestas'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None:
# Merge anidado; los límites se mergean por clave. La verificación
# de la FIRMA de la clave la hace el controlador `/licencia` antes
# de llegar aquí (ver skeen_plataforma/controllers/licencia.py).
limites = {**actual['licencia'].get('limites', {}), **bloque.pop('limites', {})}
actual['licencia'] = {**actual['licencia'], **bloque, 'limites': limites}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)
def _hhmm_to_float(valor):
"""'08:00' -> 8.0. None si el formato no es válido."""
try:
partes = str(valor).strip().split(':')
h = int(partes[0])
m = int(partes[1]) if len(partes) > 1 else 0
if not (0 <= h <= 24 and 0 <= m <= 59):
return None
return h + m / 60.0
except (ValueError, TypeError, IndexError):
return None
def agenda_bounds(env):
"""(inicio, fin) de la agenda en horas float.
Lee horario_apertura/horario_cierre de los ajustes; si faltan o son
incoherentes usa el horario de siempre (8:00-19:00), de modo que una
instancia sin configurar se comporta igual que hoy.
"""
s = get_settings(env)
inicio = _hhmm_to_float(s.get('horario_apertura'))
fin = _hhmm_to_float(s.get('horario_cierre'))
if inicio is None:
inicio = AGENDA_START_DEFAULT
if fin is None:
fin = AGENDA_END_DEFAULT
if fin <= inicio:
return AGENDA_START_DEFAULT, AGENDA_END_DEFAULT
return inicio, fin
def _es_url_privada(url):
"""True si el host es local/privado (no sirve para URLs de pago públicas)."""
try:
host = (urlparse(url).hostname or '').lower()
except ValueError:
return True
if not host or host in ('localhost', '127.0.0.1', '::1'):
return True
if host.startswith('10.') or host.startswith('192.168.'):
return True
if host.startswith('172.'):
try:
segundo = int(host.split('.')[1])
except (IndexError, ValueError):
return True
if 16 <= segundo <= 31:
return True
return False
def url_publica(env, fallback=''):
"""URL pública base de la instancia, sin barra final.
Orden: ajustes de la clínica -> brand -> `web.base.url` (solo si es público)
-> `fallback` (el valor que usaba el código antes). El último escalón
preserva el comportamiento actual en instancias sin configurar.
"""
url = (get_settings(env).get('url_publica') or '').strip()
if not url:
url = str(brand_dict(env).get('url_publica') or '').strip()
if not url:
base = env['ir.config_parameter'].sudo().get_param('web.base.url') or ''
if base and not _es_url_privada(base):
url = base
if not url:
url = fallback
return (url or '').rstrip('/')