fase 2.1: roles editables (modelo skeen.role) y permisos por accion (43 claves, 9 grupos) con enforcement en backend y gating en frontend; editor de roles/permisos/menus en Configuracion

This commit is contained in:
2026-10-11 07:32:29 +00:00
parent 75736967ef
commit 28a690aa0f
24 changed files with 1637 additions and 157 deletions

View File

@@ -13,6 +13,7 @@
'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas'],
'data': [
'security/ir.model.access.csv',
'data/roles.xml',
'data/frontend_users.xml',
],
'installable': True,

View File

@@ -15,7 +15,8 @@ import time
from odoo.http import request, Response
from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms
from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms, min_rank
from ..permissions import has_perm, PERM_LABELS
TTL_SECONDS = 60 * 60 * 12 # 12 horas
SECRET_KEY = 'skeen.frontend.secret'
@@ -158,10 +159,10 @@ def forbid_roles(*roles):
def require_role(min_role='lectura'):
"""Decorator: exige token válido y un rol con rango >= min_role.
Deja pasar OPTIONS (preflight CORS) sin autenticación.
Deja pasar OPTIONS (preflight CORS) sin autenticación. El rango mínimo se
resuelve en cada petición desde skeen.role (editable), con respaldo en
ROLE_RANKS.
"""
required = RANK.get(min_role, 10)
def decorator(func):
@functools.wraps(func)
def wrapper(self, *args, **kw):
@@ -170,7 +171,7 @@ def require_role(min_role='lectura'):
user = current_user(request.env)
if not user:
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
if user.role_rank() < required:
if user.role_rank() < min_rank(request.env, min_role):
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
if user.role == 'medico' and any(
request.httprequest.path == p or request.httprequest.path.startswith(
@@ -184,6 +185,33 @@ def require_role(min_role='lectura'):
return decorator
def require_perm(perm):
"""Decorator: exige token válido y el permiso por acción `perm`.
Se apila junto a @require_role: el rango sigue siendo el guard base y este
decorador refina la acción concreta contra 'skeen.role_perms' (defaults en
permissions.py). El rol admin siempre pasa. Un permiso ausente = acceso
denegado (403), nunca 200.
"""
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)
if not has_perm(user, perm):
return _json({
'status': 'error',
'message': 'Sin permiso: %s' % PERM_LABELS.get(perm, perm),
}, 403)
request.frontend_user = user
return func(self, *args, **kw)
return wrapper
return decorator
# ---------------------------------------------------------------------------
# Permisos de ACCION dentro de la Agenda (selector por usuario).
#

View File

@@ -9,9 +9,12 @@ 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 .auth import require_agenda, agenda_perm_error, require_perm
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
from ..roles import AGENDA_PERM_KEYS
from ..roles import AGENDA_PERM_KEYS, role_keys, role_selection
from ..permissions import (PERMISSION_GROUPS, PERM_KEYS, PERM_LABELS,
ROLE_PERMS_KEY, has_perm, all_roles_perms,
default_role_perms, resolve_role_perms, set_role_perms)
from odoo.addons.skeen_citas.clinic_settings import (
agenda_bounds as clinic_agenda_bounds,
get_settings as get_clinic_settings,
@@ -278,6 +281,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('config.usuarios')
def auth_users_list(self, **kw):
try:
if not self._puede_gestionar_usuarios():
@@ -289,6 +293,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('config.usuarios')
def auth_users_create(self, **kw):
try:
if not self._puede_gestionar_usuarios():
@@ -302,7 +307,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 ROLE_KEYS:
if role not in role_keys(request.env):
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
if role == 'admin' and not self._puede_asignar_admin():
return json_response({'status': 'error', 'message': 'Solo un administrador puede crear administradores'}, 403)
@@ -319,6 +324,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('config.usuarios')
def auth_users_update(self, user_id, **kw):
try:
if not self._puede_gestionar_usuarios():
@@ -348,7 +354,7 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Ese usuario ya existe'}, 409)
vals['login'] = login
if 'role' in data:
if data['role'] not in ROLE_KEYS:
if data['role'] not in role_keys(request.env):
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
if not self._puede_gestionar_roles():
return json_response({'status': 'error', 'message': 'Sin permiso para cambiar el rol'}, 403)
@@ -393,6 +399,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
@require_perm('config.usuarios')
def auth_users_delete(self, user_id, **kw):
try:
if not self._puede_gestionar_usuarios():
@@ -420,13 +427,18 @@ class SkeenFrontendController(http.Controller):
return json_response({
'status': 'success',
'matrix': get_menu_roles(request.env),
'roles': [{'key': k, 'label': l} for k, l in ROLE_SELECTION],
'roles': [{'key': r.code, 'label': r.name, 'active': r.active, 'rank': r.rank}
for r in request.env['skeen.role'].sudo().with_context(
active_test=False).search([], order='sequence, name')] or
[{'key': k, 'label': l, 'active': True, 'rank': 0}
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('recepcion')
@require_perm('config.roles')
def roles_permissions_put(self, **kw):
try:
if not self._puede_gestionar_roles():
@@ -435,11 +447,12 @@ class SkeenFrontendController(http.Controller):
matrix = data.get('matrix')
if not isinstance(matrix, dict):
return json_response({'status': 'error', 'message': 'matrix debe ser un objeto {href: [roles]}'}, 400)
valid_roles = role_keys(request.env)
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):
if not isinstance(roles, list) or any(not isinstance(r, str) or r not in valid_roles 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', []):
@@ -455,6 +468,264 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Roles configurables (skeen.role)
# ============================================================
# 'admin' y 'lectura' no se pueden desactivar: sin admin nadie administra y
# sin lectura no queda el rol base de solo consulta.
ROLES_PROTEGIDOS = ('admin', 'lectura')
def _role_to_dict(self, r):
return {
'id': r.id,
'code': r.code,
'name': r.name,
'rank': r.rank,
'sequence': r.sequence,
'active': r.active,
'is_system': r.is_system,
'description': r.description or '',
}
@http.route('/skeen/frontend/v1/roles', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_roles(self, **kw):
"""Roles configurados. ?include_inactive=1 trae también los inactivos."""
try:
incluir = kw.get('include_inactive') in ('1', 'true', 'True')
Role = request.env['skeen.role'].sudo()
if incluir:
Role = Role.with_context(active_test=False)
recs = Role.search([], order='sequence, name')
return json_response({'status': 'success',
'roles': [self._role_to_dict(r) for r in recs],
'total': len(recs)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/roles', type='http', auth='none',
methods=['POST'], csrf=False)
@require_role('gerencia')
@require_perm('config.roles')
def create_role(self, **kw):
"""Crea un rol. El `code` se genera como slug único del nombre."""
try:
if not self._puede_gestionar_roles():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error',
'message': 'El nombre es obligatorio'}, 400)
Role = request.env['skeen.role'].sudo()
codigo = (data.get('code') or '').strip()
if codigo:
codigo = catalog_slugify(codigo) or codigo
if Role.with_context(active_test=False).search_count([('code', '=', codigo)]):
return json_response({
'status': 'error',
'message': 'Ya existe un rol con el código %s' % codigo}, 409)
else:
codigo = catalog_unique_slug(Role, name)
try:
rank = int(data.get('rank', 50))
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'El rango debe ser numérico'}, 400)
try:
sequence = int(data.get('sequence') or 10)
except (TypeError, ValueError):
sequence = 10
rol = Role.create({
'name': name,
'code': codigo,
'rank': rank,
'sequence': sequence,
'active': bool(data.get('active', True)),
'description': data.get('description') or '',
'is_system': False,
})
self._auditar('roles', 'role:%s' % rol.id, 'crear',
new_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500],
entity_label='Rol %s' % rol.name,
notes=(data.get('motivo') or 'creado').strip())
return json_response({'status': 'success',
'role': self._role_to_dict(rol)}, 201)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/roles/<int:role_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('config.roles')
def update_role(self, role_id, **kw):
"""Renombra / ajusta rango, secuencia, estado y descripción de un rol.
El `code` NUNCA se cambia: es el valor guardado en los usuarios.
"""
try:
if not self._puede_gestionar_roles():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
rol = request.env['skeen.role'].sudo().with_context(
active_test=False).browse(role_id)
if not rol.exists():
return json_response({'status': 'error', 'message': 'Rol no encontrado'}, 404)
data = _parse_json_body()
vals = {}
if 'name' in data and str(data.get('name') or '').strip():
vals['name'] = str(data['name']).strip()
if 'description' in data:
vals['description'] = data.get('description') or ''
if 'rank' in data:
try:
vals['rank'] = int(data['rank'])
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'El rango debe ser numérico'}, 400)
if 'sequence' in data:
try:
vals['sequence'] = int(data['sequence'])
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'La secuencia debe ser numérica'}, 400)
if 'active' in data:
activo = bool(data['active'])
if not activo and rol.code in self.ROLES_PROTEGIDOS:
return json_response({
'status': 'error',
'message': 'El rol "%s" no se puede desactivar.' % rol.name}, 400)
vals['active'] = activo
if not vals:
return json_response({'status': 'error',
'message': 'Nada que actualizar'}, 400)
rol.write(vals)
self._auditar('roles', 'role:%s' % rol.id, 'editar',
new_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500],
entity_label='Rol %s' % rol.name,
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success',
'role': self._role_to_dict(rol)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/roles/<int:role_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('config.roles')
def delete_role(self, role_id, **kw):
"""Desactiva un rol (nunca se borra: los usuarios lo referencian).
Los roles de sistema no se pueden eliminar (400).
"""
try:
if not self._puede_gestionar_roles():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
rol = request.env['skeen.role'].sudo().with_context(
active_test=False).browse(role_id)
if not rol.exists():
return json_response({'status': 'error', 'message': 'Rol no encontrado'}, 404)
if rol.is_system:
return json_response({
'status': 'error',
'message': ('"%s" es un rol del sistema: no se puede eliminar, '
'solo desactivar (si aplica) o renombrar.' % rol.name)}, 400)
if rol.code in self.ROLES_PROTEGIDOS:
return json_response({
'status': 'error',
'message': 'El rol "%s" no se puede desactivar.' % rol.name}, 400)
en_uso = request.env['skeen.frontend.user'].sudo().search_count(
[('role', '=', rol.code)])
rol.write({'active': False})
self._auditar('roles', 'role:%s' % rol.id, 'desactivar',
old_value=json.dumps(self._role_to_dict(rol), ensure_ascii=False)[:500],
entity_label='Rol %s' % rol.name,
notes='desactivado (%d usuarios)' % en_uso)
mensaje = 'Rol desactivado.'
if en_uso:
mensaje = ('El rol tiene %d usuario(s); quedó desactivado y sus '
'usuarios conservan el rol.' % en_uso)
return json_response({'status': 'success',
'role': self._role_to_dict(rol),
'en_uso': en_uso, 'message': mensaje})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Permisos por acción (catálogo + por rol)
# ============================================================
def _perm_catalogo(self):
return [{'grupo': g, 'label': label,
'permisos': [{'key': k, 'label': l} for k, l in perms]}
for g, label, perms in PERMISSION_GROUPS]
@http.route('/skeen/frontend/v1/permisos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('config.roles')
def permisos_get(self, **kw):
"""Catálogo de permisos + permisos vigentes por rol + defaults."""
try:
if not self._puede_gestionar_roles():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
Role = request.env['skeen.role'].sudo().with_context(active_test=False)
return json_response({
'status': 'success',
'catalogo': self._perm_catalogo(),
'por_rol': all_roles_perms(request.env),
'defaults': default_role_perms(),
'roles': [{'code': r.code, 'name': r.name, 'rank': r.rank,
'active': r.active, 'is_system': r.is_system}
for r in Role.search([], order='sequence, name')],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/permisos', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('config.roles')
def permisos_put(self, **kw):
"""Guarda permisos de uno o varios roles.
Body: {role_code, permisos:{clave:bool}} o {por_rol:{role_code:{...}}}.
"""
try:
if not self._puede_gestionar_roles():
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
data = _parse_json_body()
validos = role_keys(request.env)
guardados = 0
if isinstance(data.get('por_rol'), dict):
por_rol = data['por_rol']
elif data.get('role_code'):
por_rol = {data['role_code']: data.get('permisos') or {}}
else:
return json_response({'status': 'error',
'message': 'Se espera role_code+permisos o por_rol'}, 400)
for code, perms in por_rol.items():
if code not in validos:
return json_response({'status': 'error',
'message': 'Rol inválido: %s' % code}, 400)
if not isinstance(perms, dict):
return json_response({'status': 'error',
'message': 'permisos de %s debe ser un objeto' % code}, 400)
if set_role_perms(request.env, code, perms):
guardados += 1
self._auditar('roles', 'role_perms', 'permisos',
new_value=json.dumps(por_rol, ensure_ascii=False)[:2000],
entity_label='Permisos por rol',
notes='%d rol(es)' % guardados)
return json_response({'status': 'success',
'por_rol': all_roles_perms(request.env),
'guardados': guardados})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Dashboard
# ============================================================
@@ -1932,6 +2203,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/patients/export', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('pacientes.exportar')
def export_patients(self, **kw):
"""Todos los pacientes activos que cumplen los filtros, SIN paginar.
@@ -1973,6 +2245,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('pacientes.crear')
def create_patient(self, **kw):
try:
data = _parse_json_body()
@@ -2048,6 +2321,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/patients/<int:patient_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
@require_perm('pacientes.editar')
def update_patient(self, patient_id, **kw):
try:
patient = request.env['res.partner'].sudo().browse(patient_id)
@@ -2642,6 +2916,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.cobrar')
@forbid_roles('marketing')
def patient_deposit(self, patient_id, **kw):
"""Recarga de saldo a favor (anticipo): dinero a cuenta del paciente.
@@ -2740,6 +3015,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.cobrar')
@forbid_roles('marketing')
def pay_patient_adeudo(self, patient_id, **kw):
"""Registra un pago del adeudo (parcial o total) y devuelve el recibo.
@@ -3450,7 +3726,8 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/services', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('admin')
@require_role('gerencia')
@require_perm('servicios.editar')
def create_service(self, **kw):
try:
data = _parse_json_body()
@@ -3492,7 +3769,8 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/services/reorder', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_role('gerencia')
@require_perm('servicios.editar')
def reorder_services(self, **kw):
"""Reordena el catálogo y renumera las secuencias (10, 20, 30...).
@@ -3533,7 +3811,8 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/services/<int:service_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('admin')
@require_role('gerencia')
@require_perm('servicios.editar')
def update_service(self, service_id, **kw):
try:
service = request.env['skeen.servicio'].sudo().browse(service_id)
@@ -4345,6 +4624,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.cobrar')
@forbid_roles('marketing')
def create_payment(self, **kw):
try:
@@ -4362,6 +4642,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/payments/<int:payment_id>/confirm', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.cobrar')
@forbid_roles('marketing')
def confirm_payment(self, payment_id, **kw):
try:
@@ -4530,6 +4811,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/pay', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.cobrar')
@forbid_roles('marketing')
def pay_sale(self, sale_id, **kw):
try:
@@ -4568,6 +4850,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.devolver')
@forbid_roles('marketing')
def refund_sale(self, sale_id, **kw):
try:
@@ -4584,6 +4867,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.cancelar')
@forbid_roles('marketing')
def cancel_sale(self, sale_id, **kw):
"""Cancelación de venta con nota de crédito (motivo obligatorio).
@@ -4747,6 +5031,7 @@ class SkeenFrontendController(http.Controller):
# ============================================================
@http.route('/skeen/frontend/v1/pos/checkout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.vender')
@forbid_roles('marketing')
def pos_checkout(self, **kw):
"""Crea la venta confirmada, aplica canje de puntos y/o registra el pago.
@@ -4767,6 +5052,11 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
discount = float(data.get('discount', 0) or 0)
if discount > 0 and not has_perm(request.frontend_user, 'ventas.descuentos'):
return json_response({
'status': 'error',
'message': 'Sin permiso: %s' % PERM_LABELS.get('ventas.descuentos', 'Aplicar descuentos'),
}, 403)
line_vals = []
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
@@ -5016,6 +5306,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/cash_closings/<int:closing_id>/close', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('caja.cerrar')
@forbid_roles('marketing')
def close_cash_closing(self, closing_id, **kw):
try:
@@ -6181,6 +6472,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/exchange_rate', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('admin')
@require_perm('config.editar')
def set_exchange_rate(self, **kw):
try:
data = _parse_json_body()
@@ -6238,6 +6530,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/sales_goal', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_perm('config.editar')
def set_sales_goal(self, **kw):
try:
data = _parse_json_body()
@@ -6262,6 +6555,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/config/commission_default', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_perm('config.editar')
def set_commission_default(self, **kw):
try:
data = _parse_json_body()
@@ -6285,6 +6579,7 @@ class SkeenFrontendController(http.Controller):
# ============================================================
@http.route('/skeen/frontend/v1/config/impact_simulation', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_perm('config.editar')
def config_impact_simulation(self, **kw):
"""Qué habría pasado con los valores configurados (o unos propuestos).
@@ -6381,6 +6676,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/config/audit', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_perm('config.auditoria')
def config_audit_list(self, **kw):
"""Bitácora de cambios de configuración (filtrable y paginada)."""
try:
@@ -6492,6 +6788,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/brand', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_perm('config.editar')
def put_brand(self, **kw):
"""Guarda el brand. Solo admin del sistema; queda en la bitácora."""
try:
@@ -6571,6 +6868,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/glosario', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('config.editar')
def put_glosario(self, **kw):
"""Guarda pack y overrides. Los overrides pisan al pack.
@@ -6632,6 +6930,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/clinic-settings', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('config.editar')
def put_clinic_settings_api(self, **kw):
"""Guarda los ajustes mergeando sobre lo actual.
@@ -6699,6 +6998,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/sucursales', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
@require_perm('config.editar')
def create_sucursal(self, **kw):
"""Crea una sucursal. Sin `code` se genera un slug del nombre."""
try:
@@ -6750,6 +7050,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('config.editar')
def update_sucursal(self, sucursal_id, **kw):
try:
if not self._puede_configurar_catalogo():
@@ -6806,6 +7107,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/sucursales/<int:sucursal_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('config.editar')
def delete_sucursal(self, sucursal_id, **kw):
"""Desactiva la sucursal (nunca se borra: las citas la referencian)."""
try:
@@ -6868,6 +7170,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/service-categories', type='http',
auth='none', methods=['POST'], csrf=False)
@require_role('gerencia')
@require_perm('config.editar')
def create_service_category(self, **kw):
"""Crea una categoría (las creadas por API nunca son de sistema)."""
try:
@@ -6914,6 +7217,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('config.editar')
def update_service_category(self, category_id, **kw):
"""Edita una categoría. `is_system` no se puede cambiar."""
try:
@@ -6964,6 +7268,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/service-categories/<int:category_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('config.editar')
def delete_service_category(self, category_id, **kw):
"""Desactiva la categoría. Las de sistema no se pueden eliminar."""
try:
@@ -7018,6 +7323,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/ticket_config', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_perm('config.editar')
def put_ticket_config(self, **kw):
"""Guarda el diseño del ticket. Requiere motivo para la bitácora."""
try:
@@ -7048,6 +7354,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/ticket_config/clinica', type='http',
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_perm('config.editar')
def put_ticket_clinica(self, **kw):
"""Perfil de la clínica que sale en el encabezado del ticket (y logo)."""
try:
@@ -7081,6 +7388,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/ticket_config/reset', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('admin')
@require_perm('config.editar')
def reset_ticket_config(self, **kw):
"""Devuelve el diseño al formato original (el del ticket de siempre)."""
try:
@@ -7244,6 +7552,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('inventario.mover')
@forbid_roles('marketing')
def inventory_create(self, **kw):
try:
@@ -7271,6 +7580,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('recepcion')
@require_perm('inventario.ajustar')
@forbid_roles('marketing')
def inventory_update(self, item_id, **kw):
try:
@@ -7303,6 +7613,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('inventario.mover')
@forbid_roles('marketing')
def inventory_move_create(self, item_id, **kw):
try:
@@ -7366,6 +7677,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/inventario/compras', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('inventario.mover')
@forbid_roles('marketing')
def inventario_compra(self, **kw):
"""Registrar compra de consumible (sube stock; actualiza costo si viene)"""
@@ -7455,6 +7767,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/inventario/bajas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('inventario.mover')
@forbid_roles('marketing')
def inventario_baja(self, **kw):
"""Registrar baja/merma de consumible (baja stock)"""
@@ -7532,6 +7845,7 @@ class SkeenFrontendController(http.Controller):
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('inventario.ajustar')
@forbid_roles('marketing')
def create_conteo(self, **kw):
"""Registra un conteo físico y aplica los ajustes por diferencia.

View File

@@ -7,7 +7,7 @@ Expone endpoints REST para el frontend SKEEN.
from odoo import http, fields
from odoo.http import request
from .frontend import json_response, _parse_json_body
from .auth import require_role
from .auth import require_role, require_perm
import os
import logging
import requests
@@ -397,6 +397,8 @@ class SkeenWacrmProxyController(http.Controller):
# Mensajes
# ============================================================
@http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('whatsapp.responder')
def send_message(self, **kw):
try:
data = _parse_json_body()
@@ -547,6 +549,8 @@ class SkeenWacrmProxyController(http.Controller):
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/assign', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('whatsapp.asignar')
def assign_conversation(self, external_id, **kw):
try:
data = _parse_json_body()
@@ -641,6 +645,8 @@ class SkeenWacrmProxyController(http.Controller):
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/leads/<int:lead_id>/status', type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('lectura')
@require_perm('whatsapp.asignar')
def update_lead_status(self, lead_id, **kw):
try:
deal = request.env['skeen.wacrm.deal'].sudo().browse(lead_id)
@@ -679,6 +685,8 @@ class SkeenWacrmProxyController(http.Controller):
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/wacrm/leads/<int:lead_id>/convert', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('whatsapp.asignar')
def convert_lead(self, lead_id, **kw):
"""Convierte el lead en paciente SKEEN (crea o liga por teléfono) y lo marca ganado"""
try:
@@ -805,6 +813,7 @@ class SkeenWacrmProxyController(http.Controller):
@http.route('/skeen/frontend/v1/wacrm/conversations/<string:external_id>/notes', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('whatsapp.responder')
def create_note(self, external_id, **kw):
try:
data = _parse_json_body()
@@ -827,6 +836,7 @@ class SkeenWacrmProxyController(http.Controller):
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('whatsapp.responder')
def update_note(self, note_id, **kw):
"""Marca/desmarca una nota como atendida, o edita su texto."""
try:
@@ -854,6 +864,7 @@ class SkeenWacrmProxyController(http.Controller):
@http.route('/skeen/frontend/v1/wacrm/notes/<int:note_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
@require_perm('whatsapp.responder')
def delete_note(self, note_id, **kw):
try:
note = request.env['skeen.wacrm.note'].sudo().browse(note_id)
@@ -876,6 +887,7 @@ class SkeenWacrmProxyController(http.Controller):
@http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('whatsapp.responder')
def create_tag(self, **kw):
try:
data = _parse_json_body()
@@ -906,6 +918,7 @@ class SkeenWacrmProxyController(http.Controller):
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('whatsapp.responder')
def update_tag(self, tag_id, **kw):
try:
tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id)
@@ -936,6 +949,7 @@ class SkeenWacrmProxyController(http.Controller):
@http.route('/skeen/frontend/v1/wacrm/tags/<int:tag_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
@require_perm('whatsapp.responder')
def delete_tag(self, tag_id, **kw):
"""Desactiva la etiqueta (conserva el histórico de asignaciones)."""
try:

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Roles semilla del frontend React. Los `code` son EXACTAMENTE los que ya
estaban guardados en skeen.frontend.user.role: no cambiarlos o los
usuarios históricos quedarían huérfanos. is_system=True: se pueden
renombrar, ajustar de rango y desactivar (salvo admin/lectura), pero no
eliminar. Los permisos por acción viven en ir.config_parameter
'skeen.role_perms' (defaults calculados en permissions.py). -->
<record id="role_admin" model="skeen.role">
<field name="name">Administrador</field>
<field name="code">admin</field>
<field name="rank">100</field>
<field name="sequence">10</field>
<field name="is_system" eval="True"/>
<field name="description">Acceso total al sistema y a la configuración.</field>
</record>
<record id="role_medico" model="skeen.role">
<field name="name">Médico</field>
<field name="code">medico</field>
<field name="rank">50</field>
<field name="sequence">20</field>
<field name="is_system" eval="True"/>
<field name="description">Atención clínica; sin pantallas de dinero.</field>
</record>
<record id="role_recepcion" model="skeen.role">
<field name="name">Recepción</field>
<field name="code">recepcion</field>
<field name="rank">50</field>
<field name="sequence">30</field>
<field name="is_system" eval="True"/>
<field name="description">Agenda, pacientes, ventas y cobros.</field>
</record>
<record id="role_enfermeria" model="skeen.role">
<field name="name">Enfermería</field>
<field name="code">enfermeria</field>
<field name="rank">50</field>
<field name="sequence">40</field>
<field name="is_system" eval="True"/>
<field name="description">Apoyo clínico y visitas.</field>
</record>
<record id="role_cosmetologia" model="skeen.role">
<field name="name">Cosmetología</field>
<field name="code">cosmetologia</field>
<field name="rank">50</field>
<field name="sequence">50</field>
<field name="is_system" eval="True"/>
<field name="description">Servicios de cosmetología.</field>
</record>
<record id="role_psicologa" model="skeen.role">
<field name="name">Psicóloga</field>
<field name="code">psicologa</field>
<field name="rank">50</field>
<field name="sequence">60</field>
<field name="is_system" eval="True"/>
<field name="description">Servicios de psicología.</field>
</record>
<record id="role_farmacia" model="skeen.role">
<field name="name">Farmacia</field>
<field name="code">farmacia</field>
<field name="rank">50</field>
<field name="sequence">70</field>
<field name="is_system" eval="True"/>
<field name="description">Dispensación de productos y medicamentos.</field>
</record>
<record id="role_gerencia" model="skeen.role">
<field name="name">Gerencia</field>
<field name="code">gerencia</field>
<field name="rank">50</field>
<field name="sequence">80</field>
<field name="is_system" eval="True"/>
<field name="description">Administra usuarios, roles y catálogos.</field>
</record>
<record id="role_marketing" model="skeen.role">
<field name="name">Marketing</field>
<field name="code">marketing</field>
<field name="rank">50</field>
<field name="sequence">90</field>
<field name="is_system" eval="True"/>
<field name="description">Agenda, pacientes y WhatsApp; sin caja.</field>
</record>
<record id="role_lectura" model="skeen.role">
<field name="name">Lectura</field>
<field name="code">lectura</field>
<field name="rank">10</field>
<field name="sequence">100</field>
<field name="is_system" eval="True"/>
<field name="description">Solo consulta, sin escritura.</field>
</record>
</odoo>

View File

@@ -1,5 +1,6 @@
from . import main
from . import wacrm_sync
from . import role
from . import frontend_user
from . import config_audit
from . import wacrm_notes

View File

@@ -5,7 +5,8 @@ import json
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms
from ..roles import ROLE_SELECTION, ROLE_RANKS, resolve_agenda_perms, role_rank_map
from ..permissions import resolve_role_perms_for_user
try:
from werkzeug.security import check_password_hash, generate_password_hash
@@ -23,7 +24,9 @@ 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(ROLE_SELECTION, string='Rol', default='recepcion', required=True)
role = fields.Selection(
selection='_get_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)
@@ -63,7 +66,14 @@ class SkeenFrontendUser(models.Model):
def role_rank(self):
"""Jerarquía simple para permisos: mayor número = más acceso."""
self.ensure_one()
return ROLE_RANKS.get(self.role, 0)
return role_rank_map(self.env).get(self.role, ROLE_RANKS.get(self.role, 0))
@api.model
def _get_role_selection(self):
"""Selección de roles configurados (incluye inactivos para no invalidar
usuarios históricos); si aún no hay roles usa la lista de siempre."""
from ..roles import role_selection
return role_selection(self.env)
def _allowed_menus_list(self):
"""Devuelve la lista de hrefs permitidos o None (usar defaults por rol)"""
@@ -99,6 +109,7 @@ class SkeenFrontendUser(models.Model):
'login': self.login,
'name': self.name,
'role': self.role,
'role_name': self._role_name(),
'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(),
@@ -106,4 +117,14 @@ class SkeenFrontendUser(models.Model):
# cruda, para que el editor de usuarios sepa si está heredando.
'agenda_perms': resolve_agenda_perms(self),
'agenda_perms_custom': self._agenda_perms_list(),
# Permisos por acción vigentes (rol + overrides), para que el
# frontend sepa qué ocultar sin consultar la configuración.
'perms': resolve_role_perms_for_user(self),
}
def _role_name(self):
"""Nombre del rol configurado (o el código si no existe)."""
self.ensure_one()
rol = self.env['skeen.role'].sudo().with_context(active_test=False).search(
[('code', '=', self.role)], limit=1)
return rol.name if rol else self.role

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenRole(models.Model):
"""Rol del frontend React.
Antes los roles eran un Selection hardcodeado en skeen.frontend.user. Ahora
son datos: el `code` es el valor que se guarda en los usuarios, por eso los
roles semilla conservan los códigos actuales y ningún usuario histórico se
invalida. Los roles de sistema (is_system) se pueden renombrar pero no
eliminar.
"""
_name = 'skeen.role'
_description = 'Rol del frontend'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True, translate=True)
code = fields.Char(
string='Código', required=True, copy=False,
help='Identificador estable que se guarda en los usuarios.')
rank = fields.Integer(
string='Rango', default=50,
help='Jerarquía: a mayor rango, más acceso de lectura/escritura. '
'Administrador=100, roles operativos=50, solo lectura=10.')
sequence = fields.Integer(string='Secuencia', default=10)
active = fields.Boolean(string='Activo', default=True)
is_system = fields.Boolean(
string='De sistema', default=False,
help='Los roles de sistema no se pueden eliminar, solo renombrar.')
description = fields.Text(string='Descripción')
_sql_constraints = [
('unique_code', 'unique(code)', 'El código del rol debe ser único!')
]

View File

@@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
"""Catálogo de permisos por ACCIÓN y defaults por rol.
A diferencia de la matriz de menús (qué secciones ve un rol) y de los permisos
de agenda por usuario, esto responde a "qué puede HACER" cada rol dentro de las
pantallas: cancelar un ticket, aplicar descuentos, cerrar caja, exportar,
administrar usuarios, etc.
Sin dependencias de Odoo para poder importarse desde modelos y controladores.
Persistencia: ir.config_parameter 'skeen.role_perms' = JSON
{role_code: {perm_key: true/false}}. Lo guardado manda sobre los defaults; no
hay que persistir los defaults (se calculan).
REGLA DE ORO: con la configuración por defecto TODO se comporta como antes.
Los defaults solo son restrictivos donde ya lo era el guard viejo
(@require_role) o donde el rol es claramente de solo lectura. Todo lo demás
queda permitido para los roles de escritura y son los guards existentes
(veto de 'medico' a dinero, forbid_roles('marketing'), _puede_gestionar_*)
los que siguen mandando. Así ningún usuario pierde acceso al desplegar.
"""
import json
ROLE_PERMS_KEY = 'skeen.role_perms'
# ---------------------------------------------------------------------------
# Catálogo: grupos -> [(clave, etiqueta)]. El orden es el de la UI.
# ---------------------------------------------------------------------------
PERMISSION_GROUPS = [
('agenda', 'Agenda', [
('agenda.ver', 'Consultar la agenda'),
('agenda.agendar', 'Agendar citas'),
('agenda.editar', 'Editar / reprogramar citas'),
('agenda.eliminar', 'Eliminar o cancelar citas'),
('agenda.mover', 'Mover citas de horario'),
('agenda.visitas', 'Generar visitas (check-in)'),
('agenda.todas', 'Ver la agenda de todos (no solo la propia)'),
('agenda.bloquear', 'Bloquear / cerrar agenda'),
]),
('pacientes', 'Pacientes', [
('pacientes.ver', 'Ver pacientes'),
('pacientes.expediente', 'Ver expediente completo'),
('pacientes.crear', 'Crear pacientes'),
('pacientes.editar', 'Editar pacientes'),
('pacientes.eliminar', 'Eliminar pacientes'),
('pacientes.exportar', 'Exportar pacientes'),
('pacientes.saldos', 'Ver saldos y adeudos'),
]),
('visitas', 'Visitas y expedientes', [
('visitas.ver', 'Ver visitas'),
('visitas.crear', 'Crear visitas / consultas'),
('visitas.editar', 'Editar visitas'),
('visitas.eliminar', 'Eliminar visitas'),
('visitas.archivos', 'Subir archivos al expediente'),
]),
('ventas', 'Ventas y cobros', [
('ventas.vender', 'Vender (punto de venta)'),
('ventas.cobrar', 'Cobrar y registrar pagos'),
('ventas.descuentos', 'Aplicar descuentos'),
('ventas.cancelar', 'Cancelar tickets / ventas'),
('ventas.devolver', 'Hacer devoluciones'),
('ventas.reimprimir', 'Reimprimir tickets'),
]),
('inventario', 'Inventario', [
('inventario.ver', 'Ver inventario'),
('inventario.ajustar', 'Ajustar existencias'),
('inventario.mover', 'Registrar movimientos / compras / bajas'),
('inventario.costos', 'Editar costos'),
]),
('servicios', 'Servicios y catálogo', [
('servicios.ver', 'Ver servicios y catálogo'),
('servicios.editar', 'Editar servicios y precios'),
]),
('reportes', 'Reportes y caja', [
('reportes.ver', 'Ver reportes'),
('reportes.exportar', 'Exportar reportes'),
('caja.ver', 'Ver cortes de caja'),
('caja.cerrar', 'Cerrar caja'),
]),
('whatsapp', 'WhatsApp y leads', [
('whatsapp.ver', 'Ver conversaciones y leads'),
('whatsapp.responder', 'Responder conversaciones'),
('whatsapp.asignar', 'Asignar conversaciones y leads'),
]),
('config', 'Usuarios y configuración', [
('config.usuarios', 'Administrar usuarios'),
('config.roles', 'Administrar roles y menús'),
('config.editar', 'Editar la configuración de la clínica'),
('config.auditoria', 'Ver la auditoría'),
]),
]
PERM_KEYS = tuple(
key for _g, _label, perms in PERMISSION_GROUPS for key, _l in perms
)
PERM_LABELS = {
key: label for _g, _label, perms in PERMISSION_GROUPS for key, label in perms
}
# Permisos que sólo son lectura: son los que conserva el rol 'lectura'.
READ_PERM_KEYS = (
'agenda.ver', 'agenda.todas',
'pacientes.ver', 'pacientes.expediente', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver',
'inventario.ver',
'servicios.ver',
'reportes.ver', 'reportes.exportar', 'caja.ver',
'ventas.reimprimir',
'whatsapp.ver',
)
# ---------------------------------------------------------------------------
# Conjuntos de roles para los defaults.
# W = roles con rango de escritura (todo menos 'lectura').
# WN = W sin marketing (a quien forbid_roles cierra los módulos de dinero).
# MONEY = W sin medico ni marketing (veto de medico a las pantallas de dinero).
# AG = admin + gerencia (los que hoy administran usuarios/roles/catálogos).
# AD = admin (lo que hoy exige @require_role('admin')).
# ALL = W + lectura.
# ---------------------------------------------------------------------------
W = ('admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
'psicologa', 'farmacia', 'gerencia', 'marketing')
WN = tuple(r for r in W if r != 'marketing')
MONEY = tuple(r for r in W if r not in ('medico', 'marketing'))
AG = ('admin', 'gerencia')
AD = ('admin',)
ALL = W + ('lectura',)
# ---------------------------------------------------------------------------
# Defaults por permiso. Tabla explicable:
# - lectura -> solo los READ_PERM_KEYS.
# - W -> escritura general (agenda, pacientes, visitas, visitas...).
# - WN / MONEY -> bloques de dinero: coinciden con require_role('recepcion')
# + forbid_roles('marketing') y con el veto de 'medico'.
# - AG / AD -> administración (hoy restringida a admin/gerencia o admin).
# ---------------------------------------------------------------------------
DEFAULT_PERM_ROLES = {
# Agenda (hoy: require_role('recepcion') para escribir)
'agenda.ver': ALL,
'agenda.agendar': W,
'agenda.editar': W,
'agenda.eliminar': W,
'agenda.mover': W,
'agenda.visitas': W,
'agenda.todas': W,
'agenda.bloquear': W,
# Pacientes
'pacientes.ver': ALL,
'pacientes.expediente': ALL,
'pacientes.crear': W,
'pacientes.editar': W,
'pacientes.eliminar': W,
'pacientes.exportar': ALL,
'pacientes.saldos': ALL,
# Visitas
'visitas.ver': ALL,
'visitas.crear': W,
'visitas.editar': W,
'visitas.eliminar': W,
'visitas.archivos': W,
# Ventas y cobros
'ventas.vender': MONEY,
'ventas.cobrar': WN, # medico también cobra depósitos de paciente hoy
'ventas.descuentos': MONEY,
'ventas.cancelar': AG, # hoy: admin/gerencia (chequeo explícito en el endpoint)
'ventas.devolver': MONEY,
'ventas.reimprimir': ALL,
# Inventario (hoy: require_role('recepcion') + forbid_roles('marketing'))
'inventario.ver': ALL,
'inventario.ajustar': WN,
'inventario.mover': WN,
'inventario.costos': WN,
# Servicios y catálogo (hoy services POST/PUT/DELETE exigen admin)
'servicios.ver': ALL,
'servicios.editar': AD,
# Reportes y caja
'reportes.ver': ALL,
'reportes.exportar': ALL,
'caja.ver': ALL,
'caja.cerrar': MONEY,
# WhatsApp y leads (menú WACRM: admin, recepcion, gerencia, marketing)
'whatsapp.ver': W,
'whatsapp.responder': W,
'whatsapp.asignar': W,
# Usuarios y configuración
'config.usuarios': AG,
'config.roles': AG,
'config.editar': AG,
'config.auditoria': AD,
}
# Los roles nuevos (creados desde la UI) arrancan como 'lectura': el admin
# decide después qué permisos les da. Si no, cualquier rol nuevo tendría acceso
# de escritura sin que nadie lo pidiera.
NEW_ROLE_BASE = 'lectura'
def default_perms_for(role_code):
"""Dict {perm_key: bool} por defecto para un rol (base por rango)."""
base_role = role_code if role_code in W or role_code == 'lectura' else NEW_ROLE_BASE
perms = {}
for key in PERM_KEYS:
if base_role == 'lectura':
perms[key] = key in READ_PERM_KEYS
else:
perms[key] = base_role in (DEFAULT_PERM_ROLES.get(key) or ())
return perms
def default_role_perms():
"""Defaults completos {role_code: {perm_key: bool}} para los 10 roles base.
No incluye roles personalizados (no se conocen sin consultar Odoo); el
frontend usa su propio espejo para eso.
"""
return {code: default_perms_for(code) for code in
('admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura')}
def _stored(env):
"""Overrides guardados {role_code: {perm_key: bool}} (o {})."""
raw = env['ir.config_parameter'].sudo().get_param(ROLE_PERMS_KEY)
if not raw:
return {}
try:
val = json.loads(raw)
except Exception:
return {}
if not isinstance(val, dict):
return {}
clean = {}
for role, perms in val.items():
if isinstance(perms, dict):
clean[role] = {k: bool(v) for k, v in perms.items() if k in PERM_KEYS}
return clean
def resolve_role_perms(env, role_code):
"""Permisos vigentes de un rol: defaults + overrides guardados."""
perms = default_perms_for(role_code)
perms.update(_stored(env).get(role_code) or {})
return perms
def resolve_role_perms_for_user(user):
"""Permisos vigentes del usuario (por su rol). None -> todo False."""
if user is None:
return {k: False for k in PERM_KEYS}
return resolve_role_perms(user.env, user.role)
def has_perm(user, perm_key):
"""True si el usuario puede ejecutar la acción. Admin siempre puede."""
if user is None:
return False
if user.role == 'admin':
return True
return bool(resolve_role_perms_for_user(user).get(perm_key))
def all_roles_perms(env):
"""{role_code: {perm_key: bool}} para todos los roles en skeen.role."""
roles = env['skeen.role'].sudo().with_context(active_test=False).search([])
codes = [r.code for r in roles if r.code]
if not codes:
return default_role_perms()
return {code: resolve_role_perms(env, code) for code in codes}
def set_role_perms(env, role_code, perms):
"""Guarda permisos de un rol (merge con lo ya guardado). False si no aplica."""
rol = env['skeen.role'].sudo().with_context(active_test=False).search(
[('code', '=', role_code)], limit=1)
if not rol:
return False
clean = {k: bool(v) for k, v in (perms or {}).items() if k in PERM_KEYS}
if not clean:
return False
stored = _stored(env)
current = dict(stored.get(role_code) or {})
current.update(clean)
stored[role_code] = current
env['ir.config_parameter'].sudo().set_param(ROLE_PERMS_KEY, json.dumps(stored))
return True
def reset_role_perms(env, role_code):
"""Quita los overrides de un rol (vuelve a los defaults)."""
stored = _stored(env)
if role_code in stored:
stored.pop(role_code)
env['ir.config_parameter'].sudo().set_param(ROLE_PERMS_KEY, json.dumps(stored))
return True

View File

@@ -42,6 +42,42 @@ ROLE_RANKS = {
'lectura': 10,
}
# ------------------------------------------------------------------
# Roles dinámicos (modelo skeen.role). ROLE_SELECTION/ROLE_RANKS quedan como
# respaldo para el arranque (antes de que la semilla XML esté disponible) y para
# no romper instalaciones viejas.
# ------------------------------------------------------------------
def role_records(env):
"""Registros de skeen.role (incluye inactivos) o lista vacía si no existe."""
try:
return env['skeen.role'].sudo().with_context(active_test=False).search([])
except KeyError:
return []
def role_selection(env):
"""[(code, name)] de los roles configurados, con respaldo estático."""
seleccion = [(r.code, r.name) for r in role_records(env) if r.code]
return seleccion or ROLE_SELECTION
def role_keys(env):
"""Códigos de rol válidos (incluye inactivos, para no invalidar históricos)."""
return tuple(k for k, _ in role_selection(env))
def role_rank_map(env):
"""{code: rank} de los roles configurados, con respaldo estático."""
mapa = {r.code: r.rank for r in role_records(env) if r.code}
return mapa or dict(ROLE_RANKS)
def min_rank(env, code):
"""Rango mínimo exigido por un código ('recepcion' -> 50 por defecto)."""
return role_rank_map(env).get(code, ROLE_RANKS.get(code, 10))
MENU_ROLES_KEY = 'skeen.menu_roles'
# ------------------------------------------------------------------
@@ -69,16 +105,30 @@ WRITE_RANK_MIN = 'recepcion'
# rango, no por nombre, para no duplicar la lista de roles.
# Mapa de cada acción de agenda al permiso por rol equivalente. El permiso de
# rol es el default; la lista propia del usuario (agenda_perms) lo pisa.
AGENDA_ROLE_PERM = {
'ver': 'agenda.ver',
'agendar': 'agenda.agendar',
'editar': 'agenda.editar',
'eliminar': 'agenda.eliminar',
'visitas': 'agenda.visitas',
}
def agenda_defaults_for(user):
"""Permisos de agenda por defecto de un usuario (sin lista propia)."""
ver = ['ver']
"""Permisos de agenda por defecto de un usuario (sin lista propia).
Se derivan de los permisos por acción del rol; 'ver' siempre se conserva.
"""
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)
return ['ver']
from .permissions import resolve_role_perms_for_user
role_perms = resolve_role_perms_for_user(user)
acciones = [a for a, key in AGENDA_ROLE_PERM.items() if role_perms.get(key)]
if 'ver' not in acciones:
acciones.insert(0, 'ver')
return acciones
def resolve_agenda_perms(user):

View File

@@ -6,6 +6,7 @@ access_skeen_wacrm_pipeline,skeen.wacrm.pipeline,model_skeen_wacrm_pipeline,base
access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_user,1,1,1,1
access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1
access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1
access_skeen_role,Roles del frontend,model_skeen_role,base.group_user,1,1,1,1
access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1
access_skeen_wacrm_note,Notas internas WACRM,model_skeen_wacrm_note,base.group_user,1,1,1,1
access_skeen_wacrm_tag,Etiquetas WACRM,model_skeen_wacrm_tag,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_skeen_wacrm_stage skeen.wacrm.stage model_skeen_wacrm_stage base.group_user 1 1 1 1
7 access_skeen_wacrm_deal skeen.wacrm.deal model_skeen_wacrm_deal base.group_user 1 1 1 1
8 access_skeen_frontend_user skeen.frontend.user model_skeen_frontend_user base.group_user 1 1 1 1
9 access_skeen_role Roles del frontend model_skeen_role base.group_user 1 1 1 1
10 access_skeen_config_audit skeen.config.audit model_skeen_config_audit base.group_user 1 1 1 1
11 access_skeen_wacrm_note Notas internas WACRM model_skeen_wacrm_note base.group_user 1 1 1 1
12 access_skeen_wacrm_tag Etiquetas WACRM model_skeen_wacrm_tag base.group_user 1 1 1 1