fase 1 productizacion: capa de configuracion (glosario multi-giro, sucursales y categorias configurables, ajustes de clinica por instancia, colores de marca, horarios, URLs de pago dinamicas) + script de provisioning de instancia nueva con datos demo + frontend consumiendo la config
This commit is contained in:
166
odoo-addons/skeen_citas/clinic_settings.py
Normal file
166
odoo-addons/skeen_citas/clinic_settings.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# -*- 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
|
||||
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',
|
||||
)
|
||||
|
||||
# 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': '',
|
||||
}
|
||||
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])
|
||||
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()
|
||||
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('/')
|
||||
Reference in New Issue
Block a user