# -*- 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'] # --------------------------------------------------------------------------- # 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}' ), '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') NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion', 'plantilla_cumpleanos', 'plantilla_post_visita', 'plantilla_formulario', 'plantilla_renovacion_membresia', 'plantilla_cobro_membresia') 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')) # --------------------------------------------------------------------------- # 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.. # # `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), 'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])), } 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['licencia'] = normalizar_licencia(guardado.get('licencia')) 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 '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('/')