636 lines
26 KiB
Python
636 lines
26 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 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}.'
|
|
),
|
|
'whatsapp_numero': '',
|
|
'email_remitente': '',
|
|
}
|
|
|
|
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
|
|
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
|
|
'sucursal', 'telefono')
|
|
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
|
|
'plantilla_cumpleanos', 'plantilla_post_visita')
|
|
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'))
|
|
|
|
# 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),
|
|
}
|
|
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'))
|
|
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}
|
|
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('/')
|