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

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

View File

@@ -6,6 +6,7 @@ import base64
import json
from datetime import date, datetime, timedelta
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):
@@ -100,7 +101,7 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'login y name son obligatorios'}, 400)
if len(password) < 8:
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)
User = request.env['skeen.frontend.user'].sudo()
if User.search([('login', '=', login)], limit=1):
@@ -123,7 +124,7 @@ class SkeenFrontendController(http.Controller):
if 'name' in data:
vals['name'] = data['name']
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)
vals['role'] = data['role']
if 'active' in data:
@@ -159,6 +160,43 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
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
# ============================================================

View File

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