white-label: brand de la clinica por configuracion (fase 1 de instancia sin branding SKEEN)

- backend: GET/PUT /skeen/frontend/v1/brand leyendo ir.config_parameter
  skeen.clinic.brand (JSON: nombre, logo_claro, logo_oscuro, pie) con
  defaults SKEEN; GET publico (el login lo necesita sin sesion), PUT admin
  con auditoria. frontend_users.xml: 'Administrador SKEEN' -> 'Administrador'
- frontend: ClinicBrand + BRAND_DEFAULT en odoo.ts; brand y setBrand en el
  contexto de auth (fetch al boot + cache localStorage + document.title)
- Login/layouts/mensajes/exports leen el brand en vez de strings SKEEN:
  Login.tsx, SkeenLayout, HomeNestLayout, Cumpleanos (wa + export),
  Pacientes/Ventas/Cortes/Reportes/Kpis/Alertas/AgendaPrint (titulos),
  useClinicaPrint (fallback nombre), Login hace GET publico al montar
- Configuracion > Clinica: tarjeta 'Identidad de la clinica' (solo admin)
  para editar nombre/pie/logos con preview
- no renombrado (invisible al usuario): rutas API /skeen/*, claves
  localStorage, nombres de modulos, parametros skeen.*

Verificacion API: 11/11 tests OK (defaults, PUT merge, campo invalido
ignorado, restore, admin neutral). Deploy: build + rsync + Odoo reiniciado
This commit is contained in:
2026-10-08 00:09:00 +00:00
parent 2573562218
commit 2f7cbe237e
18 changed files with 302 additions and 48 deletions

View File

@@ -6244,6 +6244,79 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Brand de la clínica (nombre, logos, pie) — white-label
# ============================================================
BRAND_KEY = 'skeen.clinic.brand'
def _brand_dict(self):
"""Brand vigente: lo guardado en ir.config_parameter o defaults SKEEN.
Este endpoint es lo que permite desplegar la plataforma para otra
clínica sin tocar código: la instancia nueva solo cambia este
parámetro y sube sus logos. Los campos:
nombre - nombre comercial (login, títulos, mensajes, tickets)
logo_claro - logo para fondos oscuros (login SKEEN, sidebar)
logo_oscuro - logo para fondos claros (tema HomeNest)
pie - línea del pie (login / documentos impresos)
"""
defaults = {
'nombre': 'SKEEN Derma Experts',
'logo_claro': '/skeen-brand/logos/Logo%20Completo%20Blanco.png',
'logo_oscuro': '/skeen-brand/logos/Logo%20Completo%20Negro.png',
'pie': 'SKEEN Derma Experts · Playas de Rosarito, B.C.',
}
raw = request.env['ir.config_parameter'].sudo().get_param(self.BRAND_KEY)
if raw:
try:
guardado = json.loads(raw)
if isinstance(guardado, dict):
defaults.update({k: str(v) for k, v in guardado.items()
if k in defaults and v is not None and str(v).strip()})
except Exception:
pass # JSON roto: se sirven los defaults
return defaults
@http.route('/skeen/frontend/v1/brand', type='http',
auth='none', methods=['GET', 'OPTIONS'], csrf=False)
def get_brand(self, **kw):
"""Brand de la clínica. PÚBLICO: el login (sin sesión) lo necesita
para pintar nombre/logo de la clínica antes de autenticar. Solo expone
nombre y URLs de logos, nada sensible."""
try:
return json_response({'status': 'success', 'brand': self._brand_dict()})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/brand', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('admin')
def put_brand(self, **kw):
"""Guarda el brand. Solo admin del sistema; queda en la bitácora."""
try:
data = _parse_json_body()
brand = data.get('brand')
if not isinstance(brand, dict):
return json_response({'status': 'error',
'message': 'brand debe ser un objeto'}, 400)
antes = self._brand_dict()
limpio = {k: str(brand[k]).strip() for k in
('nombre', 'logo_claro', 'logo_oscuro', 'pie')
if brand.get(k) is not None and str(brand.get(k)).strip()}
request.env['ir.config_parameter'].sudo().set_param(
self.BRAND_KEY, json.dumps(limpio, ensure_ascii=False))
nuevo = self._brand_dict()
self._auditar('clinica', 'brand', 'brand',
old_value=json.dumps(antes, ensure_ascii=False)[:500],
new_value=json.dumps(nuevo, ensure_ascii=False)[:500],
entity_label='Identidad de la clínica (brand)',
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success', 'brand': nuevo,
'message': 'Identidad de la clínica actualizada'})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Diseño de tickets (Configuración › Tickets)
# ============================================================

View File

@@ -5,7 +5,7 @@
Cámbiala en cuanto se tenga pantalla de usuarios. -->
<record id="seed_admin" model="skeen.frontend.user">
<field name="login">admin</field>
<field name="name">Administrador SKEEN</field>
<field name="name">Administrador</field>
<field name="password_hash">scrypt:32768:8:1$qUN8LJrWj2F6a5Fe$699e6d72d42004abb0fb401843c2ccb497eccc39600ea2e7160eda276148fbc246c3a1e777eb3d44161cfe1cd86bc490e35c062cabf8c3d753cd170c478ae06a</field>
<field name="role">admin</field>
<field name="active" eval="True"/>