1438 lines
61 KiB
Python
1438 lines
61 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Ajustes de la clínica por instancia (white-label).
|
|
|
|
Vive en `skeen.clinic.settings` (JSON) y no tiene modelo propio. Antes estos
|
|
datos solo existían en el localStorage del navegador de cada equipo; ahora son
|
|
compartidos por toda la instancia y los consumen el frontend, la impresión de
|
|
tickets y las URLs de pago.
|
|
|
|
El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no
|
|
cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
from datetime import date
|
|
from urllib.parse import urlparse
|
|
|
|
SETTINGS_KEY = 'skeen.clinic.settings'
|
|
BRAND_KEY = 'skeen.clinic.brand'
|
|
BRAND_NOMBRE_DEFAULT = 'SKEEN Derma Experts'
|
|
|
|
HORA_APERTURA_DEFAULT = '08:00'
|
|
HORA_CIERRE_DEFAULT = '19:00'
|
|
ZONA_HORARIA_DEFAULT = 'America/Tijuana'
|
|
MONEDA_DEFAULT = 'MXN'
|
|
|
|
# Campos del contrato del endpoint.
|
|
CAMPOS = (
|
|
'nombre', 'telefono', 'email', 'direccion', 'web', 'rfc',
|
|
'horario_apertura', 'horario_cierre', 'zona_horaria', 'url_publica',
|
|
'moneda_base', 'nota_ticket',
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Impuestos (bloque anidado dentro del mismo JSON de ajustes).
|
|
#
|
|
# El impuesto es propio y simple: `account`/`l10n_mx` están desinstalados, así
|
|
# que no hay motor fiscal de Odoo. Con `activo=False` TODO el sistema se
|
|
# comporta como antes (sin cálculo, sin desglose, sin renglón en tickets); esa
|
|
# es la regla de oro y por eso los defaults son apagados y en cero.
|
|
#
|
|
# activo: enciende el cálculo en POS, tickets y reportes
|
|
# nombre: etiqueta del impuesto (p. ej. 'IVA')
|
|
# tasa_default: % que se aplica cuando el catálogo no fija una
|
|
# precios_incluyen_impuesto: el precio capturado YA trae impuesto; el
|
|
# subtotal se desglosa hacia atrás
|
|
# tasa_opciones: tasas ofrecidas en los formularios (%)
|
|
# ---------------------------------------------------------------------------
|
|
IMPUESTOS_DEFAULT = {
|
|
'activo': False,
|
|
'nombre': 'IVA',
|
|
'tasa_default': 0.0,
|
|
'precios_incluyen_impuesto': False,
|
|
'tasa_opciones': [0, 8, 16],
|
|
}
|
|
|
|
|
|
def _num(valor, por_defecto=0.0):
|
|
"""Número finito de un valor JSON, sin explotar con None/texto."""
|
|
try:
|
|
n = float(valor)
|
|
except (TypeError, ValueError):
|
|
return por_defecto
|
|
if n != n or n in (float('inf'), float('-inf')): # NaN / infinitos
|
|
return por_defecto
|
|
return n
|
|
|
|
|
|
def _bool(valor, por_defecto=False):
|
|
if isinstance(valor, bool):
|
|
return valor
|
|
if valor in ('1', 'true', 'True', 'si', 'sí', 1):
|
|
return True
|
|
if valor in ('0', 'false', 'False', 'no', 0):
|
|
return False
|
|
return por_defecto
|
|
|
|
|
|
def normalizar_impuestos(raw):
|
|
"""Bloque `impuestos` completo, con defaults, a partir de lo guardado.
|
|
|
|
Tolerante: nunca lanza. Lo inválido cae al default (una instancia sin
|
|
configurar debe verse EXACTAMENTE como hoy).
|
|
"""
|
|
out = dict(IMPUESTOS_DEFAULT)
|
|
if not isinstance(raw, dict):
|
|
return out
|
|
out['activo'] = _bool(raw.get('activo'), IMPUESTOS_DEFAULT['activo'])
|
|
out['precios_incluyen_impuesto'] = _bool(
|
|
raw.get('precios_incluyen_impuesto'),
|
|
IMPUESTOS_DEFAULT['precios_incluyen_impuesto'])
|
|
nombre = str(raw.get('nombre') or '').strip()
|
|
out['nombre'] = (nombre or IMPUESTOS_DEFAULT['nombre'])[:40]
|
|
out['tasa_default'] = min(max(_num(raw.get('tasa_default')), 0.0), 100.0)
|
|
opciones = raw.get('tasa_opciones')
|
|
if isinstance(opciones, list):
|
|
limpias = []
|
|
for o in opciones:
|
|
n = _num(o, None)
|
|
if n is None:
|
|
continue
|
|
n = min(max(n, 0.0), 100.0)
|
|
if n not in limpias:
|
|
limpias.append(n)
|
|
if limpias:
|
|
out['tasa_opciones'] = sorted(limpias)
|
|
return out
|
|
|
|
|
|
def validar_impuestos(raw):
|
|
"""Valida el bloque `impuestos` de un PUT. Lanza ValueError si algo no
|
|
cuadra (se traduce a 400 en el controlador).
|
|
|
|
Devuelve SOLO las claves presentes (ya normalizadas) para que el merge no
|
|
reinicie lo que el PUT no tocó."""
|
|
if raw is None:
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
raise ValueError('El bloque de impuestos debe ser un objeto')
|
|
out = {}
|
|
if 'activo' in raw:
|
|
if not isinstance(raw['activo'], bool):
|
|
raise ValueError('El campo activo de impuestos debe ser verdadero o falso')
|
|
out['activo'] = raw['activo']
|
|
if 'precios_incluyen_impuesto' in raw:
|
|
if not isinstance(raw['precios_incluyen_impuesto'], bool):
|
|
raise ValueError('precios_incluyen_impuesto debe ser verdadero o falso')
|
|
out['precios_incluyen_impuesto'] = raw['precios_incluyen_impuesto']
|
|
if 'nombre' in raw:
|
|
if not isinstance(raw['nombre'], str) or not raw['nombre'].strip():
|
|
raise ValueError('El nombre del impuesto no puede estar vacío')
|
|
if len(raw['nombre'].strip()) > 40:
|
|
raise ValueError('El nombre del impuesto es demasiado largo (máx. 40)')
|
|
out['nombre'] = raw['nombre'].strip()[:40]
|
|
if 'tasa_default' in raw:
|
|
n = _num(raw['tasa_default'], None)
|
|
if n is None:
|
|
raise ValueError('La tasa por defecto debe ser un número')
|
|
if not (0 <= n <= 100):
|
|
raise ValueError('La tasa por defecto debe estar entre 0 y 100')
|
|
out['tasa_default'] = n
|
|
if 'tasa_opciones' in raw:
|
|
opciones = raw['tasa_opciones']
|
|
if not isinstance(opciones, list) or not opciones:
|
|
raise ValueError('tasa_opciones debe ser una lista de números')
|
|
limpias = []
|
|
for o in opciones:
|
|
n = _num(o, None)
|
|
if n is None:
|
|
raise ValueError('Cada opción de tasa debe ser un número')
|
|
if not (0 <= n <= 100):
|
|
raise ValueError('Las opciones de tasa deben estar entre 0 y 100')
|
|
if n not in limpias:
|
|
limpias.append(n)
|
|
out['tasa_opciones'] = sorted(limpias)
|
|
return out
|
|
|
|
|
|
def get_impuestos(env):
|
|
"""Bloque de impuestos vigente (copia). Fuente única para modelos y API."""
|
|
return normalizar_impuestos(get_settings(env).get('impuestos'))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reservas en línea (portal público de auto-agendado).
|
|
#
|
|
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
|
# oro: con `activo=False` NADA cambia en la instancia (el portal público
|
|
# responde 404 y el frontend no muestra enlaces). Por eso el default es apagado.
|
|
# ---------------------------------------------------------------------------
|
|
RESERVAS_ONLINE_DEFAULT = {
|
|
'activo': False,
|
|
'titulo': 'Reserva tu cita en línea',
|
|
'descripcion': '',
|
|
'requiere_confirmacion': True,
|
|
'permitir_eleccion_doctor': True,
|
|
'permitir_sucursal': False,
|
|
'max_dias_futuro': 60,
|
|
'min_horas_anticipacion': 2,
|
|
'permitir_cancelar': True,
|
|
'cancelar_hasta_horas': 24,
|
|
'mensaje_confirmacion': '',
|
|
'telefono_contacto': '',
|
|
}
|
|
|
|
# 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'))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor))>
|
|
#
|
|
# `payload_json` usa claves ordenadas y separadores mínimos, para que el CLI del
|
|
# vendedor (infra/licencias/generar_licencia.py, Python stdlib) genere
|
|
# exactamente los mismos bytes. La clave del vendedor se configura en la
|
|
# instancia por:
|
|
# 1) ir.config_parameter 'skeen.licencia.vendor_key'
|
|
# 2) variable de entorno SKEEN_LICENCIA_VENDOR_KEY
|
|
# 3) archivo /root/.skeen-licencias/vendor.key (permisos 600)
|
|
# Sin clave de vendedor configurada, ninguna licencia se puede validar (y sin
|
|
# licencia aplicada el sistema se comporta como siempre).
|
|
# ---------------------------------------------------------------------------
|
|
LICENCIA_PREFIJO = 'SKN1'
|
|
LICENCIA_VENDOR_KEY_ICP = 'skeen.licencia.vendor_key'
|
|
LICENCIA_VENDOR_KEY_ENV = 'SKEEN_LICENCIA_VENDOR_KEY'
|
|
LICENCIA_VENDOR_KEY_FILE = '/root/.skeen-licencias/vendor.key'
|
|
|
|
|
|
def vendor_key(env=None):
|
|
"""Clave del vendedor vigente (ICP -> entorno -> archivo). '' si no hay."""
|
|
key = ''
|
|
if env is not None:
|
|
try:
|
|
key = env['ir.config_parameter'].sudo().get_param(LICENCIA_VENDOR_KEY_ICP) or ''
|
|
except Exception:
|
|
key = ''
|
|
if not key:
|
|
key = os.environ.get(LICENCIA_VENDOR_KEY_ENV) or ''
|
|
if not key:
|
|
try:
|
|
with open(LICENCIA_VENDOR_KEY_FILE, 'r', encoding='utf-8') as fh:
|
|
key = (fh.read() or '').strip()
|
|
except OSError:
|
|
key = ''
|
|
return key
|
|
|
|
|
|
def _b64url(raw):
|
|
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
|
|
|
|
|
def _b64url_decode(texto):
|
|
return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4))
|
|
|
|
|
|
def _payload_canonico(payload):
|
|
return json.dumps(payload, sort_keys=True, separators=(',', ':'),
|
|
ensure_ascii=False).encode('utf-8')
|
|
|
|
|
|
def firmar_licencia(payload, vendor):
|
|
"""Devuelve la clave de licencia firmada (mismo algoritmos que el CLI)."""
|
|
cuerpo = _b64url(_payload_canonico(payload))
|
|
firma = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
|
|
hashlib.sha256).digest()
|
|
return '%s.%s.%s' % (LICENCIA_PREFIJO, cuerpo, _b64url(firma))
|
|
|
|
|
|
def verificar_clave_licencia(env, clave):
|
|
"""Verifica firma y vigencia. Devuelve (payload, error).
|
|
|
|
`payload` es el dict de la licencia si la clave es válida; `error` es un
|
|
mensaje claro de texto si no lo es (uno de los dos es None)."""
|
|
clave = (clave or '').strip()
|
|
if not clave:
|
|
return None, 'No se recibió ninguna clave de licencia.'
|
|
partes = clave.split('.')
|
|
if len(partes) != 3 or partes[0] != LICENCIA_PREFIJO:
|
|
return None, 'La clave de licencia no tiene el formato esperado.'
|
|
vendor = vendor_key(env)
|
|
if not vendor:
|
|
return None, ('No hay clave de vendedor configurada en esta instancia '
|
|
'(skeen.licencia.vendor_key).')
|
|
_, cuerpo, firma = partes
|
|
esperada = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
|
|
hashlib.sha256).digest()
|
|
try:
|
|
recibida = _b64url_decode(firma)
|
|
except Exception:
|
|
return None, 'La firma de la clave de licencia está corrupta.'
|
|
if not hmac.compare_digest(esperada, recibida):
|
|
return None, 'La firma de la clave no es válida (clave manipulada).'
|
|
try:
|
|
payload = json.loads(_b64url_decode(cuerpo).decode('utf-8'))
|
|
except Exception:
|
|
return None, 'El contenido de la clave de licencia está corrupto.'
|
|
if not isinstance(payload, dict):
|
|
return None, 'El contenido de la clave de licencia no es válido.'
|
|
vence = str(payload.get('vence') or '').strip()
|
|
if vence:
|
|
try:
|
|
d = date.fromisoformat(vence)
|
|
except ValueError:
|
|
return None, 'La fecha de vencimiento de la clave no es válida.'
|
|
if d < date.today():
|
|
return None, 'Esta licencia venció el %s.' % vence
|
|
return payload, None
|
|
|
|
|
|
def licencia_desde_payload(payload, clave, modo=None):
|
|
"""Bloque `licencia` a guardar a partir de una clave ya validada."""
|
|
payload = payload or {}
|
|
plan = str(payload.get('plan') or '').strip().lower()
|
|
modulos = payload.get('modulos')
|
|
if not isinstance(modulos, list):
|
|
modulos = modulos_de_plan(plan) if plan in PLANES else []
|
|
return normalizar_licencia({
|
|
'plan': plan if plan in LICENCIA_PLANES else 'custom',
|
|
'modo': modo or 'bloqueo',
|
|
'cliente': payload.get('cliente') or '',
|
|
'vence': payload.get('vence') or '',
|
|
'clave': clave,
|
|
'modulos': modulos,
|
|
'limites': payload.get('limites') or {},
|
|
})
|
|
|
|
|
|
def dias_para_vencer(licencia):
|
|
"""Días restantes hasta el vencimiento (None si no hay fecha)."""
|
|
vence = str((licencia or {}).get('vence') or '').strip()
|
|
if not vence:
|
|
return None
|
|
try:
|
|
return (date.fromisoformat(vence) - date.today()).days
|
|
except ValueError:
|
|
return None
|
|
|
|
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
|
AGENDA_START_DEFAULT = 8.0
|
|
AGENDA_END_DEFAULT = 19.0
|
|
|
|
|
|
def brand_dict(env):
|
|
"""Brand vigente (subconjunto suficiente), sin defaults de logos."""
|
|
raw = env['ir.config_parameter'].sudo().get_param(BRAND_KEY)
|
|
if raw:
|
|
try:
|
|
guardado = json.loads(raw)
|
|
except (ValueError, TypeError):
|
|
guardado = None
|
|
if isinstance(guardado, dict):
|
|
return guardado
|
|
return {}
|
|
|
|
|
|
def brand_nombre(env):
|
|
nombre = brand_dict(env).get('nombre')
|
|
nombre = str(nombre).strip() if nombre is not None else ''
|
|
return nombre or BRAND_NOMBRE_DEFAULT
|
|
|
|
|
|
def get_settings(env, aplicar_licencia=True):
|
|
"""Objeto completo de ajustes, con defaults si no hay nada guardado.
|
|
|
|
`aplicar_licencia=False` devuelve lo GUARDADO sin apagar por licencia (lo
|
|
necesita set_settings: si persistiera el estado ya apagado por la licencia,
|
|
quitar la licencia no restauraría los módulos)."""
|
|
out = {
|
|
'nombre': brand_nombre(env),
|
|
'telefono': '',
|
|
'email': '',
|
|
'direccion': '',
|
|
'web': '',
|
|
'rfc': '',
|
|
'horario_apertura': HORA_APERTURA_DEFAULT,
|
|
'horario_cierre': HORA_CIERRE_DEFAULT,
|
|
'zona_horaria': ZONA_HORARIA_DEFAULT,
|
|
'url_publica': '',
|
|
'moneda_base': MONEDA_DEFAULT,
|
|
'nota_ticket': '',
|
|
'impuestos': dict(IMPUESTOS_DEFAULT),
|
|
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
|
|
'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:
|
|
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}
|
|
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('/')
|