Roles ampliados (10) y matriz de permisos por rol

- Roles: admin, médico, recepción, enfermería, cosmetología, psicóloga,
  farmacia, entrenador, marketing + lectura (compatibilidad)
- Matriz de permisos por rol persistida en Odoo (ir.config_parameter)
  y editable en Configuración → Roles (secciones × roles)
- Prioridad: permisos personales del usuario > matriz por rol > defaults
- Protección: admin siempre conserva /configuracion
This commit is contained in:
2026-08-14 08:47:27 +00:00
parent be42974030
commit 281fb34588
9 changed files with 346 additions and 29 deletions

View File

@@ -0,0 +1,132 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Save, Info, Lock } from 'lucide-react';
import { Button, Skeleton, toast } from './ui';
import { odooApi, type FrontendRole } from '../services/odoo';
import { ROLE_LABELS } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items';
const ROLES = Object.keys(ROLE_LABELS) as FrontendRole[];
type Matrix = Record<string, FrontendRole[]>;
const RolesPanel: FC = () => {
const [matrix, setMatrix] = useState<Matrix | null>(null);
const [saved, setSaved] = useState<Matrix | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getRolePermissions();
if (res.status === 'success') {
setMatrix(res.matrix);
setSaved(res.matrix);
} else {
toast.error(res.message || 'No se pudieron cargar los permisos');
}
} catch (err) {
toast.error('Error al cargar permisos por rol');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const dirty = matrix !== null && saved !== null && JSON.stringify(matrix) !== JSON.stringify(saved);
const toggle = (href: string, role: FrontendRole) => {
if (!matrix) return;
if (href === '/configuracion' && role === 'admin') return; // siempre requerido
const current = matrix[href] || [];
const next = current.includes(role) ? current.filter((r) => r !== role) : [...current, role];
setMatrix({ ...matrix, [href]: next });
};
const save = async () => {
if (!matrix) return;
try {
setSaving(true);
const res = await odooApi.updateRolePermissions(matrix);
if (res.status === 'success') {
setMatrix(res.matrix);
setSaved(res.matrix);
toast.success('Permisos por rol guardados');
} else {
toast.error(res.message || 'No se pudieron guardar los permisos');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar permisos');
console.error(err);
} finally {
setSaving(false);
}
};
if (loading || !matrix) {
return <Skeleton count={6} className="h-10 w-full" />;
}
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<p className="text-sm text-theme-muted flex items-start gap-2 max-w-2xl">
<Info size={16} className="mt-0.5 shrink-0" />
Aplica a todos los usuarios del rol al recargar su sesión. Los permisos personales de un usuario van encima.
</p>
<div className="flex items-center gap-3 shrink-0">
{dirty && <span className="text-xs font-medium text-amber-600">Cambios sin guardar</span>}
<Button onClick={save} loading={saving} disabled={!dirty}>
<Save size={16} className="mr-2" />
Guardar
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-3 py-2 sticky left-0 bg-theme-bg">Sección</th>
{ROLES.map((r) => (
<th key={r} className="px-2 py-2 text-center font-medium">{ROLE_LABELS[r]}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{MENU_ITEMS.map((item) => (
<tr key={item.href} className="hover:bg-theme-bg">
<td className="px-3 py-2 font-medium text-theme-heading whitespace-nowrap sticky left-0 bg-theme-surface">
{item.label}
</td>
{ROLES.map((role) => {
const locked = item.href === '/configuracion' && role === 'admin';
const checked = (matrix[item.href] || []).includes(role);
return (
<td key={role} className="px-2 py-2 text-center">
<input
type="checkbox"
checked={checked}
disabled={locked}
onChange={() => toggle(item.href, role)}
title={locked ? 'El admin siempre requiere Configuración' : `${item.label}${ROLE_LABELS[role]}`}
className="rounded border-theme-border-strong disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
/>
{locked && <Lock size={10} className="inline ml-1 text-theme-muted" />}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default RolesPanel;

View File

@@ -17,7 +17,7 @@ import { ROLE_LABELS, useAuth } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items'; import { MENU_ITEMS } from '../lib/menu-items';
const roleOptions: { value: FrontendRole; label: string }[] = ( const roleOptions: { value: FrontendRole; label: string }[] = (
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[] Object.keys(ROLE_LABELS) as FrontendRole[]
).map((r) => ({ value: r, label: ROLE_LABELS[r] })); ).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
const roleBadge = (role: FrontendRole) => { const roleBadge = (role: FrontendRole) => {

View File

@@ -4,12 +4,19 @@ import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo'
const TOKEN_KEY = 'skeen_token'; const TOKEN_KEY = 'skeen_token';
const USER_KEY = 'skeen_user'; const USER_KEY = 'skeen_user';
const MENU_ROLES_KEY = 'skeen_menu_roles';
const ROLE_RANK: Record<FrontendRole, number> = { const ROLE_RANK: Record<FrontendRole, number> = {
lectura: 10, admin: 100,
medico: 50, medico: 50,
recepcion: 50, recepcion: 50,
admin: 100, enfermeria: 50,
cosmetologia: 50,
psicologa: 50,
farmacia: 50,
entrenador: 10,
marketing: 10,
lectura: 10,
}; };
// Visibilidad del menú por ruta. Si una ruta no está listada, se muestra a todos los roles. // Visibilidad del menú por ruta. Si una ruta no está listada, se muestra a todos los roles.
@@ -60,12 +67,35 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<FrontendUser | null>(null); const [user, setUser] = useState<FrontendUser | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY)); const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Matriz dinámica de permisos por rol (cache en localStorage; fallback a MENU_ROLES)
const [menuRoles, setMenuRoles] = useState<Record<string, FrontendRole[]> | null>(() => {
try {
const cached = localStorage.getItem(MENU_ROLES_KEY);
return cached ? JSON.parse(cached) : null;
} catch {
return null;
}
});
const refreshMenuRoles = useCallback(async () => {
try {
const res = await odooApi.getRolePermissions();
if (res.status === 'success' && res.matrix) {
setMenuRoles(res.matrix);
localStorage.setItem(MENU_ROLES_KEY, JSON.stringify(res.matrix));
}
} catch {
// fallback: MENU_ROLES hardcodeado
}
}, []);
const clear = useCallback(() => { const clear = useCallback(() => {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
localStorage.removeItem(MENU_ROLES_KEY);
setToken(null); setToken(null);
setUser(null); setUser(null);
setMenuRoles(null);
}, []); }, []);
// Validar token al arrancar // Validar token al arrancar
@@ -87,6 +117,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
if (res.status === 'success') { if (res.status === 'success') {
setUser(res.user); setUser(res.user);
localStorage.setItem(USER_KEY, JSON.stringify(res.user)); localStorage.setItem(USER_KEY, JSON.stringify(res.user));
refreshMenuRoles();
} else { } else {
clear(); clear();
} }
@@ -94,7 +125,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
.catch(() => { if (alive) clear(); }) .catch(() => { if (alive) clear(); })
.finally(() => { if (alive) setLoading(false); }); .finally(() => { if (alive) setLoading(false); });
return () => { alive = false; }; return () => { alive = false; };
}, [clear]); }, [clear, refreshMenuRoles]);
const login = async (login: string, password: string): Promise<FrontendUser> => { const login = async (login: string, password: string): Promise<FrontendUser> => {
const res = await odooApi.login(login, password); const res = await odooApi.login(login, password);
@@ -105,6 +136,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
localStorage.setItem(USER_KEY, JSON.stringify(res.user)); localStorage.setItem(USER_KEY, JSON.stringify(res.user));
setToken(res.token); setToken(res.token);
setUser(res.user); setUser(res.user);
refreshMenuRoles();
return res.user; return res.user;
}; };
@@ -120,11 +152,11 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const canSee = (href: string): boolean => { const canSee = (href: string): boolean => {
if (!user) return false; if (!user) return false;
// Permisos personalizados: lista no vacía manda sobre los defaults por rol // Permisos personalizados: lista no vacía manda sobre la matriz por rol
if (user.allowed_menus && user.allowed_menus.length > 0) { if (user.allowed_menus && user.allowed_menus.length > 0) {
return user.allowed_menus.includes(href); return user.allowed_menus.includes(href);
} }
const allowed = MENU_ROLES[href]; const allowed = (menuRoles || MENU_ROLES)[href];
if (!allowed) return true; if (!allowed) return true;
return allowed.includes(user.role); return allowed.includes(user.role);
}; };
@@ -144,7 +176,13 @@ export const useAuth = (): AuthContextValue => {
export const ROLE_LABELS: Record<FrontendRole, string> = { export const ROLE_LABELS: Record<FrontendRole, string> = {
admin: 'Administrador', admin: 'Administrador',
recepcion: 'Recepción',
medico: 'Médico', medico: 'Médico',
lectura: 'Solo lectura', recepcion: 'Recepción',
enfermeria: 'Enfermería',
cosmetologia: 'Cosmetología',
psicologa: 'Psicóloga',
farmacia: 'Farmacia',
entrenador: 'Entrenador',
marketing: 'Marketing',
lectura: 'Lectura',
}; };

View File

@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react'; import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import UsuariosPanel from '../components/UsuariosPanel'; import UsuariosPanel from '../components/UsuariosPanel';
import RolesPanel from '../components/RolesPanel';
import RecetasPanel from '../components/RecetasPanel'; import RecetasPanel from '../components/RecetasPanel';
import CatalogoPanel from '../components/CatalogoPanel'; import CatalogoPanel from '../components/CatalogoPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui'; import { Card, Button, Input, PageHeader, toast } from '../components/ui';
@@ -12,6 +13,7 @@ import { odooApi } from '../services/odoo';
const TABS = [ const TABS = [
{ key: 'clinica', label: 'Clínica' }, { key: 'clinica', label: 'Clínica' },
{ key: 'usuarios', label: 'Usuarios' }, { key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
{ key: 'recetas', label: 'Recetas' }, { key: 'recetas', label: 'Recetas' },
{ key: 'catalogos', label: 'Catálogos' }, { key: 'catalogos', label: 'Catálogos' },
] as const; ] as const;
@@ -134,6 +136,12 @@ const Configuracion: FC = () => {
<UsuariosPanel /> <UsuariosPanel />
</Card.Body> </Card.Body>
</Card> </Card>
) : tab === 'roles' ? (
<Card>
<Card.Body>
<RolesPanel />
</Card.Body>
</Card>
) : tab === 'recetas' ? ( ) : tab === 'recetas' ? (
<Card> <Card>
<Card.Body> <Card.Body>

View File

@@ -476,7 +476,7 @@ export interface WacrmLead {
created_at: string | null; created_at: string | null;
} }
export type FrontendRole = 'admin' | 'recepcion' | 'medico' | 'lectura'; export type FrontendRole = 'admin' | 'medico' | 'recepcion' | 'enfermeria' | 'cosmetologia' | 'psicologa' | 'farmacia' | 'entrenador' | 'marketing' | 'lectura';
export interface FrontendUser { export interface FrontendUser {
id: number; id: number;
@@ -783,6 +783,16 @@ export const odooApi = {
return data; return data;
}, },
async getRolePermissions(): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; roles: { key: FrontendRole; label: string }[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`);
return data;
},
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
return data;
},
// Health // Health
async healthCheck(): Promise<unknown> { async healthCheck(): Promise<unknown> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`); const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);

View File

@@ -15,15 +15,12 @@ import time
from odoo.http import request, Response from odoo.http import request, Response
from ..roles import ROLE_RANKS
TTL_SECONDS = 60 * 60 * 12 # 12 horas TTL_SECONDS = 60 * 60 * 12 # 12 horas
SECRET_KEY = 'skeen.frontend.secret' SECRET_KEY = 'skeen.frontend.secret'
RANK = { RANK = ROLE_RANKS
'lectura': 10,
'medico': 50,
'recepcion': 50,
'admin': 100,
}
def _cors_headers(): def _cors_headers():

View File

@@ -6,6 +6,7 @@ import base64
import json import json
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from .auth import sign_token, current_user, require_role, verify_token from .auth import sign_token, current_user, require_role, verify_token
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
def json_response(data, status=200): def json_response(data, status=200):
@@ -100,7 +101,7 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400) return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400)
if len(password) < 8: if len(password) < 8:
return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400) return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
if role not in ('admin', 'recepcion', 'medico', 'lectura'): if role not in ROLE_KEYS:
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400) return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
User = request.env['skeen.frontend.user'].sudo() User = request.env['skeen.frontend.user'].sudo()
if User.search([('login', '=', login)], limit=1): if User.search([('login', '=', login)], limit=1):
@@ -123,7 +124,7 @@ class SkeenFrontendController(http.Controller):
if 'name' in data: if 'name' in data:
vals['name'] = data['name'] vals['name'] = data['name']
if 'role' in data: if 'role' in data:
if data['role'] not in ('admin', 'recepcion', 'medico', 'lectura'): if data['role'] not in ROLE_KEYS:
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400) return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
vals['role'] = data['role'] vals['role'] = data['role']
if 'active' in data: if 'active' in data:
@@ -159,6 +160,43 @@ class SkeenFrontendController(http.Controller):
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Permisos de menú por rol (matriz dinámica)
# ============================================================
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def roles_permissions_get(self, **kw):
try:
return json_response({
'status': 'success',
'matrix': get_menu_roles(request.env),
'roles': [{'key': k, 'label': l} for k, l in ROLE_SELECTION],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('admin')
def roles_permissions_put(self, **kw):
try:
data = _parse_json_body()
matrix = data.get('matrix')
if not isinstance(matrix, dict):
return json_response({'status': 'error', 'message': 'matrix debe ser un objeto {href: [roles]}'}, 400)
clean = {}
for href, roles in matrix.items():
if not isinstance(href, str):
return json_response({'status': 'error', 'message': 'Las llaves de matrix deben ser strings (href)'}, 400)
if not isinstance(roles, list) or any(not isinstance(r, str) or r not in ROLE_KEYS for r in roles):
return json_response({'status': 'error', 'message': f'Roles inválidos para {href}'}, 400)
clean[href] = list(dict.fromkeys(roles))
if 'admin' not in clean.get('/configuracion', []):
return json_response({'status': 'error', 'message': "No se puede quitar '/configuracion' al rol admin"}, 400)
request.env['ir.config_parameter'].sudo().set_param(MENU_ROLES_KEY, json.dumps(clean))
return json_response({'status': 'success', 'matrix': clean})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================ # ============================================================
# Dashboard # Dashboard
# ============================================================ # ============================================================

View File

@@ -5,6 +5,8 @@ import json
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from ..roles import ROLE_SELECTION, ROLE_RANKS
try: try:
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
except Exception: # pragma: no cover except Exception: # pragma: no cover
@@ -21,12 +23,7 @@ class SkeenFrontendUser(models.Model):
login = fields.Char(string='Usuario', required=True, index=True) login = fields.Char(string='Usuario', required=True, index=True)
name = fields.Char(string='Nombre', required=True) name = fields.Char(string='Nombre', required=True)
password_hash = fields.Char(string='Hash de contraseña', required=True) password_hash = fields.Char(string='Hash de contraseña', required=True)
role = fields.Selection([ role = fields.Selection(ROLE_SELECTION, string='Rol', default='recepcion', required=True)
('admin', 'Administrador'),
('recepcion', 'Recepción'),
('medico', 'Médico'),
('lectura', 'Solo lectura'),
], string='Rol', default='recepcion', required=True)
active = fields.Boolean(string='Activo', default=True) active = fields.Boolean(string='Activo', default=True)
must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False) must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False)
last_login = fields.Datetime(string='Último acceso', readonly=True) last_login = fields.Datetime(string='Último acceso', readonly=True)
@@ -58,12 +55,7 @@ class SkeenFrontendUser(models.Model):
def role_rank(self): def role_rank(self):
"""Jerarquía simple para permisos: mayor número = más acceso.""" """Jerarquía simple para permisos: mayor número = más acceso."""
self.ensure_one() self.ensure_one()
return { return ROLE_RANKS.get(self.role, 0)
'lectura': 10,
'medico': 50,
'recepcion': 50,
'admin': 100,
}.get(self.role, 0)
def _allowed_menus_list(self): def _allowed_menus_list(self):
"""Devuelve la lista de hrefs permitidos o None (usar defaults por rol)""" """Devuelve la lista de hrefs permitidos o None (usar defaults por rol)"""

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""Roles del frontend React y matriz de permisos de menú por rol.
Módulo sin dependencias de Odoo para poder importarse tanto desde modelos
como desde controladores sin problemas de orden de carga.
La matriz se persiste en ir.config_parameter 'skeen.menu_roles' como JSON
{href: [roles...]}. Si no existe, se usan los defaults de DEFAULT_MENU_ROLES
(mismos que MENU_ROLES hardcodeado del frontend) y los 6 roles nuevos heredan
el mismo set de secciones que 'lectura'.
"""
import json
ROLE_SELECTION = [
('admin', 'Administrador'),
('medico', 'Médico'),
('recepcion', 'Recepción'),
('enfermeria', 'Enfermería'),
('cosmetologia', 'Cosmetología'),
('psicologa', 'Psicóloga'),
('farmacia', 'Farmacia'),
('entrenador', 'Entrenador'),
('marketing', 'Marketing'),
('lectura', 'Lectura'),
]
ROLE_KEYS = tuple(k for k, _ in ROLE_SELECTION)
ROLE_RANKS = {
'admin': 100,
'medico': 50,
'recepcion': 50,
'enfermeria': 50,
'cosmetologia': 50,
'psicologa': 50,
'farmacia': 50,
'entrenador': 10,
'marketing': 10,
'lectura': 10,
}
MENU_ROLES_KEY = 'skeen.menu_roles'
# Defaults = MENU_ROLES de src/lib/auth.tsx (4 roles originales).
DEFAULT_MENU_ROLES = {
'/': ['admin', 'recepcion', 'medico', 'lectura'],
'/agenda': ['admin', 'recepcion', 'medico'],
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
'/pos': ['admin', 'recepcion'],
'/visitas': ['admin', 'recepcion', 'medico'],
'/consultas': ['admin', 'recepcion', 'medico'],
'/expedientes': ['admin', 'recepcion', 'medico'],
'/medicos': ['admin', 'recepcion'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura'],
'/productos': ['admin', 'recepcion', 'lectura'],
'/ventas': ['admin', 'recepcion'],
'/pagos': ['admin', 'recepcion'],
'/monedero': ['admin', 'recepcion', 'lectura'],
'/inventario': ['admin', 'recepcion', 'lectura'],
'/cortes': ['admin', 'recepcion'],
'/reportes': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=paquetes': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura'],
'/configuracion': ['admin'],
'/cumpleanos': ['admin', 'recepcion'],
'/wacrm/messages': ['admin', 'recepcion'],
'/wacrm/leads': ['admin', 'recepcion'],
}
# Roles nuevos que por default ven lo mismo que 'lectura'.
READ_ONLY_NEW_ROLES = ('enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'entrenador', 'marketing')
def default_menu_roles():
"""Matriz por default: MENU_ROLES + roles nuevos donde esté 'lectura'."""
matrix = {}
for href, roles in DEFAULT_MENU_ROLES.items():
r = list(roles)
if 'lectura' in roles:
r.extend(nr for nr in READ_ONLY_NEW_ROLES if nr not in r)
matrix[href] = r
return matrix
def get_menu_roles(env):
"""Matriz vigente: la guardada en ir.config_parameter o los defaults."""
raw = env['ir.config_parameter'].sudo().get_param(MENU_ROLES_KEY)
if raw:
try:
val = json.loads(raw)
if isinstance(val, dict):
return val
except Exception:
pass
return default_menu_roles()