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:
132
frontend/src/components/RolesPanel.tsx
Normal file
132
frontend/src/components/RolesPanel.tsx
Normal 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;
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -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)"""
|
||||||
|
|||||||
102
odoo-addons/skeen_whatsapp/roles.py
Normal file
102
odoo-addons/skeen_whatsapp/roles.py
Normal 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()
|
||||||
Reference in New Issue
Block a user