# -*- 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('/')