permisos de agenda: selector de acciones por usuario (ver, agendar, editar, eliminar, visitas)
- roles.py: AGENDA_PERMS, AGENDA_PERM_KEYS, WRITE_RANK_MIN, agenda_defaults_for, resolve_agenda_perms, AGENDA_STATUS_PERM, AGENDA_STATE_PERM, _agenda_status_perm - frontend_user.py: campo agenda_perms, _agenda_perms_list(), to_public_dict() expone agenda_perms y agenda_perms_custom - auth.py: require_agenda(perm) decorador + agenda_perm_error() - frontend.py: @require_agenda aplicado a 22 endpoints + branch en update_appointment_status por accion - auth_users_update: whitelist acepta agenda_perms - Odoo upgrade: columna skeen_frontend_user.agenda_perms - Frontend: types AgendaPerm, AGENDA_PERM_ITEMS, canAgenda() en auth.tsx - UsuariosPanel: checkboxes de permisos de agenda en modal de permisos - Agenda.tsx: gates por permiso en crear/editar/eliminar/visitas/dragdrop/bloqueos - EstadoChecklist/BloqueosModal/ListaEspera: hide actions without permission Verificacion API: 18/18 tests OK (admin hereda, recorte=403, lect=403, restore=null)
This commit is contained in:
@@ -15,7 +15,7 @@ import time
|
||||
|
||||
from odoo.http import request, Response
|
||||
|
||||
from ..roles import ROLE_RANKS, get_menu_roles
|
||||
from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms
|
||||
|
||||
TTL_SECONDS = 60 * 60 * 12 # 12 horas
|
||||
SECRET_KEY = 'skeen.frontend.secret'
|
||||
@@ -182,3 +182,46 @@ def require_role(min_role='lectura'):
|
||||
return func(self, *args, **kw)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permisos de ACCION dentro de la Agenda (selector por usuario).
|
||||
#
|
||||
# require_role sigue siendo el guard de rango: el token no lleva permisos,
|
||||
# asi que no hay listas cacheadas que se puedan desincronizar. Este decorador
|
||||
# REFINA dentro de la agenda y solo puede QUITAR acceso, nunca darlo por
|
||||
# encima del rango del rol.
|
||||
# ---------------------------------------------------------------------------
|
||||
AGENDA_PERM_LABELS = dict(AGENDA_PERMS)
|
||||
|
||||
|
||||
def agenda_perm_error(user, perm):
|
||||
"""Response 403 si la accion no esta permitida; None si si lo esta."""
|
||||
if user is None or perm not in resolve_agenda_perms(user):
|
||||
label = AGENDA_PERM_LABELS.get(perm, perm)
|
||||
return _json({'status': 'error', 'message': 'Sin permiso: %s' % label}, 403)
|
||||
return None
|
||||
|
||||
|
||||
def require_agenda(perm):
|
||||
"""Decorator: exige token valido y la accion de agenda indicada.
|
||||
|
||||
Se apila por DEBAJO de require_role (decorador interno), de modo que el
|
||||
control de rango del rol ya corrió y solo falta refinar la acción concreta.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
user = current_user(request.env)
|
||||
if not user:
|
||||
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
||||
denied = agenda_perm_error(user, perm)
|
||||
if denied is not None:
|
||||
return denied
|
||||
request.frontend_user = user
|
||||
return func(self, *args, **kw)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@@ -9,7 +9,9 @@ import re
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from .auth import sign_token, current_user, require_role, forbid_roles, verify_token
|
||||
from .auth import require_agenda, agenda_perm_error
|
||||
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
||||
from ..roles import AGENDA_PERM_KEYS
|
||||
|
||||
|
||||
def json_response(data, status=200):
|
||||
@@ -33,6 +35,35 @@ STATE_FLOW = ['pending', 'confirmed', 'arrived', 'in_progress', 'done']
|
||||
# Estados que liberan el horario (no ocupan agenda)
|
||||
INACTIVE_STATES = ('cancelled', 'no_show', 'waitlist')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permiso de agenda que exige cada accion del checklist de estado.
|
||||
# 'visitas' = pasar a En cabina (arrive), que es lo que genera la visita del dia.
|
||||
# 'cancel' es la baja de la cita -> eliminar. 'waitlist' mete a la lista de
|
||||
# espera -> agendar. El resto son ediciones sobre la cita.
|
||||
# ---------------------------------------------------------------------------
|
||||
AGENDA_STATUS_PERM = {
|
||||
'arrive': 'visitas',
|
||||
'cancel': 'eliminar',
|
||||
'waitlist': 'agendar',
|
||||
'confirm': 'editar',
|
||||
'start': 'editar',
|
||||
'done': 'editar',
|
||||
'no_show': 'editar',
|
||||
'back': 'editar',
|
||||
}
|
||||
# Estado destino -> permiso, para 'next' y 'set_state' (no nombran la accion).
|
||||
AGENDA_STATE_PERM = {'arrived': 'visitas', 'cancelled': 'eliminar'}
|
||||
|
||||
|
||||
def _agenda_status_perm(action, target=None):
|
||||
"""Permiso de agenda requerido por un cambio de estado de la cita."""
|
||||
perm = AGENDA_STATUS_PERM.get(action)
|
||||
if perm:
|
||||
return perm
|
||||
if target:
|
||||
return AGENDA_STATE_PERM.get(target, 'editar')
|
||||
return 'editar'
|
||||
|
||||
|
||||
def _parse_json_body():
|
||||
"""Lee y parsea el body JSON de la petición"""
|
||||
@@ -306,6 +337,12 @@ class SkeenFrontendController(http.Controller):
|
||||
if menus is not None and not isinstance(menus, list):
|
||||
return json_response({'status': 'error', 'message': 'allowed_menus debe ser una lista'}, 400)
|
||||
vals['allowed_menus'] = json.dumps(menus) if menus else False
|
||||
if 'agenda_perms' in data:
|
||||
perms = data.get('agenda_perms')
|
||||
if perms is not None and not isinstance(perms, list):
|
||||
return json_response({'status': 'error', 'message': 'agenda_perms debe ser una lista o null'}, 400)
|
||||
# None = heredar del rol (dejar el campo vacío)
|
||||
vals['agenda_perms'] = json.dumps(perms) if perms is not None else False
|
||||
if vals:
|
||||
previos = {k: getattr(user, k) for k in vals}
|
||||
user.write(vals)
|
||||
@@ -565,6 +602,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/appointments', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def create_appointment(self, **kw):
|
||||
"""Crear una o más citas (soporta paquetes de sesiones)"""
|
||||
try:
|
||||
@@ -632,6 +670,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/appointments/quick', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def create_quick_appointment(self, **kw):
|
||||
"""Cita rápida: solo nombre + teléfono (+ fecha/hora/médico del contexto).
|
||||
|
||||
@@ -743,6 +782,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('editar')
|
||||
def update_appointment(self, appointment_id, **kw):
|
||||
try:
|
||||
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
||||
@@ -778,6 +818,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('eliminar')
|
||||
def delete_appointment(self, appointment_id, **kw):
|
||||
try:
|
||||
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
||||
@@ -806,6 +847,19 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
|
||||
data = _parse_json_body()
|
||||
action = data.get('action')
|
||||
# El permiso depende del estado destino: 'next' y 'set_state' no
|
||||
# nombran la accion, asi que se resuelve contra la cita real.
|
||||
_target = None
|
||||
if action == 'next':
|
||||
_idx = STATE_FLOW.index(cita.state) if cita.state in STATE_FLOW else -1
|
||||
if 0 <= _idx < len(STATE_FLOW) - 1:
|
||||
_target = STATE_FLOW[_idx + 1]
|
||||
elif action == 'set_state':
|
||||
_target = data.get('state')
|
||||
denied = agenda_perm_error(
|
||||
request.frontend_user, _agenda_status_perm(action, _target))
|
||||
if denied is not None:
|
||||
return denied
|
||||
user = request.frontend_user.login
|
||||
note = (data.get('reason') or '').strip()
|
||||
if action == 'confirm':
|
||||
@@ -880,6 +934,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('editar')
|
||||
def appointment_phone_confirm(self, appointment_id, **kw):
|
||||
"""Toggle de la 1ra confirmación (telefónica) de una cita.
|
||||
|
||||
@@ -913,6 +968,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('editar')
|
||||
def appointment_package_finished(self, appointment_id, **kw):
|
||||
try:
|
||||
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
|
||||
@@ -931,6 +987,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/appointments/bulk_phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('editar')
|
||||
def bulk_phone_confirm(self, **kw):
|
||||
"""Confirmación telefónica en lote.
|
||||
|
||||
@@ -1099,6 +1156,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/bloqueos', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def create_bloqueo(self, **kw):
|
||||
"""Crea uno o varios bloqueos de agenda.
|
||||
|
||||
@@ -1213,6 +1271,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def update_bloqueo(self, bloqueo_id, **kw):
|
||||
"""Edita un bloqueo existente (fecha, horario, motivo, médico)."""
|
||||
try:
|
||||
@@ -1255,6 +1314,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/bloqueos/<int:bloqueo_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def delete_bloqueo(self, bloqueo_id, **kw):
|
||||
try:
|
||||
bloqueo = request.env['skeen.cita.bloqueo'].sudo().browse(bloqueo_id)
|
||||
@@ -1269,6 +1329,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/bloqueos/grupo/<string:grupo>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def delete_bloqueo_grupo(self, grupo, **kw):
|
||||
"""Elimina la serie completa (mismo `grupo`) de bloqueos."""
|
||||
try:
|
||||
@@ -1370,6 +1431,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/lista_espera', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def create_lista_espera(self, **kw):
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
@@ -1402,6 +1464,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def update_lista_espera(self, registro_id, **kw):
|
||||
"""Edita la espera o la resuelve (state agendado/descartado, cita_id)."""
|
||||
try:
|
||||
@@ -1445,6 +1508,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/lista_espera/<int:registro_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('agendar')
|
||||
def delete_lista_espera(self, registro_id, **kw):
|
||||
try:
|
||||
record = request.env['skeen.lista.espera'].sudo().browse(registro_id)
|
||||
@@ -1552,6 +1616,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('visitas')
|
||||
def update_visita(self, visita_id, **kw):
|
||||
try:
|
||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||
@@ -1567,6 +1632,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/complete', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('visitas')
|
||||
def complete_visita(self, visita_id, **kw):
|
||||
try:
|
||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||
@@ -1579,6 +1645,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('visitas')
|
||||
def cancel_visita(self, visita_id, **kw):
|
||||
try:
|
||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||
@@ -1591,6 +1658,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/consultas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('visitas')
|
||||
def create_consulta(self, **kw):
|
||||
"""Consulta médica directa: visita completada sin cita origen"""
|
||||
try:
|
||||
@@ -1618,6 +1686,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('visitas')
|
||||
def add_visita_insumo(self, visita_id, **kw):
|
||||
try:
|
||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||
@@ -1644,6 +1713,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/insumos/<int:line_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('visitas')
|
||||
def delete_visita_insumo(self, visita_id, line_id, **kw):
|
||||
try:
|
||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||
@@ -1661,6 +1731,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/visitas/<int:visita_id>/adjuntos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('visitas')
|
||||
def add_visita_adjunto(self, visita_id, **kw):
|
||||
try:
|
||||
visita = request.env['skeen.visita'].sudo().browse(visita_id)
|
||||
@@ -1695,6 +1766,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/visitas/adjuntos/<int:adj_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@require_agenda('visitas')
|
||||
def delete_visita_adjunto(self, adj_id, **kw):
|
||||
try:
|
||||
adj = request.env['skeen.visita.adjunto'].sudo().browse(adj_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from ..roles import ROLE_SELECTION, ROLE_RANKS
|
||||
from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms
|
||||
|
||||
try:
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
@@ -34,6 +34,10 @@ class SkeenFrontendUser(models.Model):
|
||||
notes = fields.Text(string='Notas')
|
||||
allowed_menus = fields.Text(string='Menús Permitidos',
|
||||
help='JSON array de hrefs visibles; vacío = defaults por rol')
|
||||
agenda_perms = fields.Text(
|
||||
string='Permisos de Agenda',
|
||||
help="JSON array con las acciones permitidas dentro de la Agenda "
|
||||
"(ver, agendar, editar, eliminar, visitas). Vacío = heredar del rol.")
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_login', 'unique(login)', 'El usuario ya existe.'),
|
||||
@@ -72,6 +76,22 @@ class SkeenFrontendUser(models.Model):
|
||||
return None
|
||||
return val if isinstance(val, list) and val else None
|
||||
|
||||
def _agenda_perms_list(self):
|
||||
"""Lista explícita de acciones de agenda, o None si el usuario hereda del rol.
|
||||
|
||||
A diferencia de allowed_menus, una lista vacía SÍ es un valor válido
|
||||
("desmarcar todo" = solo consultar); por eso solo se devuelve None
|
||||
cuando el campo está vacío o el JSON es inválido.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.agenda_perms:
|
||||
return None
|
||||
try:
|
||||
val = json.loads(self.agenda_perms)
|
||||
except Exception:
|
||||
return None
|
||||
return val if isinstance(val, list) else None
|
||||
|
||||
def to_public_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
@@ -82,4 +102,8 @@ class SkeenFrontendUser(models.Model):
|
||||
'must_change_password': self.must_change_password,
|
||||
'last_login': self.last_login.strftime('%Y-%m-%d %H:%M') if self.last_login else None,
|
||||
'allowed_menus': self._allowed_menus_list(),
|
||||
# Lista resuelta (lo que realmente puede hacer) + la lista propia
|
||||
# cruda, para que el editor de usuarios sepa si está heredando.
|
||||
'agenda_perms': resolve_agenda_perms(self),
|
||||
'agenda_perms_custom': self._agenda_perms_list(),
|
||||
}
|
||||
|
||||
@@ -44,6 +44,57 @@ ROLE_RANKS = {
|
||||
|
||||
MENU_ROLES_KEY = 'skeen.menu_roles'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Permisos de ACCIÓN dentro de la Agenda (selector por usuario).
|
||||
#
|
||||
# No son visibilidad de menú: deciden qué se puede HACER una vez dentro.
|
||||
# Claves estables (se guardan como JSON en skeen.frontend.user.agenda_perms).
|
||||
# ------------------------------------------------------------------
|
||||
AGENDA_PERMS = [
|
||||
('ver', 'Consultar agenda'),
|
||||
('agendar', 'Agendar cita'),
|
||||
('editar', 'Editar citas'),
|
||||
('eliminar', 'Eliminar citas'),
|
||||
('visitas', 'Generar visitas'),
|
||||
]
|
||||
AGENDA_PERM_KEYS = tuple(k for k, _ in AGENDA_PERMS)
|
||||
|
||||
# Acciones que requieren rango de escritura (recepción para arriba): quien no
|
||||
# tiene lista propia configurada hereda esto, que es exactamente lo que ya
|
||||
# permitía @require_role('recepcion'). Así el selector puede QUITAR sin
|
||||
# configurar nada y no cambia el comportamiento actual de nadie.
|
||||
WRITE_RANK_MIN = 'recepcion'
|
||||
|
||||
# Roles sin rango propio en ROLE_RANKS no existen; el default se resuelve por
|
||||
# rango, no por nombre, para no duplicar la lista de roles.
|
||||
|
||||
|
||||
def agenda_defaults_for(user):
|
||||
"""Permisos de agenda por defecto de un usuario (sin lista propia)."""
|
||||
ver = ['ver']
|
||||
if user is None:
|
||||
return list(ver)
|
||||
if user.role == 'admin':
|
||||
return list(AGENDA_PERM_KEYS)
|
||||
if user.role_rank() >= ROLE_RANKS.get(WRITE_RANK_MIN, 50):
|
||||
return list(AGENDA_PERM_KEYS)
|
||||
return list(ver)
|
||||
|
||||
|
||||
def resolve_agenda_perms(user):
|
||||
"""Permisos de agenda vigentes: los del usuario si tiene lista, si no el default.
|
||||
|
||||
'ver' nunca se quita: sin él no hay pantalla que mostrar y el resto de las
|
||||
acciones carece de sentido. Se fuerza al subir.
|
||||
"""
|
||||
custom = user._agenda_perms_list() if user is not None else None
|
||||
if custom is None:
|
||||
return agenda_defaults_for(user)
|
||||
valid = [k for k in AGENDA_PERM_KEYS if k in custom]
|
||||
if 'ver' not in valid:
|
||||
valid.insert(0, 'ver')
|
||||
return valid
|
||||
|
||||
# Defaults = MENU_ROLES de src/lib/auth.tsx (4 roles originales).
|
||||
# 'gerencia' (gerencia de la clínica) ve toda la
|
||||
# operación EXCEPTO /configuracion (solo admin del sistema), /mis-ventas,
|
||||
|
||||
Reference in New Issue
Block a user