# -*- 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 json import re 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': '', } # Rangos razonables de validación del PUT (días / horas). RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX = 1, 365 RESERVAS_MIN_ANTICIPACION_MAX = 168 # 1 semana RESERVAS_CANCELAR_HASTA_MAX = 168 # 1 semana def _int(valor, por_defecto=0): try: return int(valor) except (TypeError, ValueError): return por_defecto def normalizar_reservas_online(raw): """Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza.""" out = dict(RESERVAS_ONLINE_DEFAULT) if not isinstance(raw, dict): return out for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor', 'permitir_sucursal', 'permitir_cancelar'): out[k] = _bool(raw.get(k), RESERVAS_ONLINE_DEFAULT[k]) out['titulo'] = (str(raw.get('titulo') or '').strip() or RESERVAS_ONLINE_DEFAULT['titulo'])[:120] out['descripcion'] = str(raw.get('descripcion') or '')[:500] out['mensaje_confirmacion'] = str(raw.get('mensaje_confirmacion') or '')[:500] out['telefono_contacto'] = str(raw.get('telefono_contacto') or '').strip()[:40] out['max_dias_futuro'] = min(max(_int(raw.get('max_dias_futuro'), RESERVAS_ONLINE_DEFAULT['max_dias_futuro']), RESERVAS_MAX_DIAS_MIN), RESERVAS_MAX_DIAS_MAX) out['min_horas_anticipacion'] = min(max(_int(raw.get('min_horas_anticipacion'), RESERVAS_ONLINE_DEFAULT['min_horas_anticipacion']), 0), RESERVAS_MIN_ANTICIPACION_MAX) out['cancelar_hasta_horas'] = min(max(_int(raw.get('cancelar_hasta_horas'), RESERVAS_ONLINE_DEFAULT['cancelar_hasta_horas']), 0), RESERVAS_CANCELAR_HASTA_MAX) return out def validar_reservas_online(raw): """Valida el bloque `reservas_online` de un PUT. Lanza ValueError si algo no cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas para que el merge no reinicie lo que el PUT no tocó.""" if raw is None: return None if not isinstance(raw, dict): raise ValueError('El bloque de reservas en línea debe ser un objeto') out = {} for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor', 'permitir_sucursal', 'permitir_cancelar'): if k in raw: if not isinstance(raw[k], bool): raise ValueError('El campo %s debe ser verdadero o falso' % k) out[k] = raw[k] for k, limite in (('titulo', 120), ('descripcion', 500), ('mensaje_confirmacion', 500), ('telefono_contacto', 40)): if k in raw: if not isinstance(raw[k], str): raise ValueError('El campo %s debe ser texto' % k) if len(raw[k].strip()) > limite: raise ValueError('El campo %s es demasiado largo (máx. %d)' % (k, limite)) out[k] = raw[k].strip() if 'titulo' in raw and not raw['titulo'].strip(): raise ValueError('El título no puede estar vacío') rangos = ( ('max_dias_futuro', RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX), ('min_horas_anticipacion', 0, RESERVAS_MIN_ANTICIPACION_MAX), ('cancelar_hasta_horas', 0, RESERVAS_CANCELAR_HASTA_MAX), ) for k, lo, hi in rangos: if k in raw: if isinstance(raw[k], bool) or not isinstance(raw[k], int): raise ValueError('El campo %s debe ser un número entero' % k) if not (lo <= raw[k] <= hi): raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi)) out[k] = raw[k] return out def get_reservas_online(env): """Bloque de reservas en línea vigente (copia). Fuente única para la API.""" return normalizar_reservas_online(get_settings(env).get('reservas_online')) # --------------------------------------------------------------------------- # 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}.' ), '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') 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 # --------------------------------------------------------------------------- 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, } 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 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']) 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'] 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')) # --------------------------------------------------------------------------- # 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')) # 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): """Objeto completo de ajustes, con defaults si no hay nada guardado.""" 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': dict(RESERVAS_ONLINE_DEFAULT), 'notificaciones': normalizar_notificaciones(None), 'membresias': normalizar_membresias(None), 'recursos': dict(RECURSOS_DEFAULT), 'compras': dict(COMPRAS_DEFAULT), } 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')) 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) 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: actual['reservas_online'] = {**actual['reservas_online'], **bloque} 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: actual['membresias'] = {**actual['membresias'], **bloque} 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} 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('/')