feat(permissions): granular module permissions + workshop permissions matrix
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

This commit is contained in:
2026-07-03 07:55:17 +00:00
parent 4718a09f15
commit 6e85497e42
7 changed files with 484 additions and 136 deletions

View File

@@ -45,6 +45,47 @@ _DEFAULT_ROLE_PERMISSIONS = {
}
# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS.
_WORKSHOP_STATUSES = [
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
'por_entregar', 'entregado', 'por_enviar', 'enviado',
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
]
# Workshop-specific actions configurable per role.
_WORKSHOP_ACTIONS = [
{'key': 'create_order', 'label': 'Crear órdenes'},
{'key': 'edit_order', 'label': 'Editar órdenes'},
{'key': 'delete_order', 'label': 'Eliminar órdenes'},
{'key': 'assign_mechanic', 'label': 'Asignar mecánico'},
{'key': 'add_items', 'label': 'Agregar artículos'},
{'key': 'add_labor', 'label': 'Agregar mano de obra'},
{'key': 'change_status', 'label': 'Cambiar estatus'},
{'key': 'convert_to_sale', 'label': 'Convertir a venta'},
{'key': 'convert_to_remission', 'label': 'Generar nota de remisión'},
{'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'},
{'key': 'view_prices', 'label': 'Ver precios/costos'},
{'key': 'view_notes', 'label': 'Ver bitácora'},
]
# Default workshop permissions per role. Admins see everything; restricted roles only work statuses.
_DEFAULT_WORKSHOP_PERMISSIONS = {
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]},
'workshop': {
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
'actions': ['change_status', 'add_labor', 'view_notes']
},
'mechanic': {
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
'actions': ['change_status', 'add_labor', 'view_notes']
},
}
_AVAILABLE_PERMISSIONS = [
{'module': 'Dashboard', 'permissions': [
{'key': 'dashboard.view', 'label': 'Ver Dashboard'},
@@ -55,23 +96,33 @@ _AVAILABLE_PERMISSIONS = [
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
{'key': 'pos.remission', 'label': 'Notas de remisión'},
{'key': 'pos.view_cost', 'label': 'Ver costos en POS'},
]},
{'module': 'Inventario', 'permissions': [
{'key': 'inventory.view', 'label': 'Ver inventario'},
{'key': 'inventory.create', 'label': 'Crear artículos'},
{'key': 'inventory.edit', 'label': 'Editar/eliminar artículos'},
{'key': 'inventory.edit', 'label': 'Editar artículos'},
{'key': 'inventory.delete', 'label': 'Eliminar artículos'},
{'key': 'inventory.adjust', 'label': 'Ajustar stock'},
{'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'},
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
]},
{'module': 'Catálogo', 'permissions': [
{'key': 'catalog.view', 'label': 'Ver catálogo'},
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
]},
{'module': 'Clientes', 'permissions': [
{'key': 'customers.view', 'label': 'Ver clientes'},
{'key': 'customers.create', 'label': 'Crear/editar clientes'},
{'key': 'customers.create', 'label': 'Crear clientes'},
{'key': 'customers.edit', 'label': 'Editar clientes'},
{'key': 'customers.delete', 'label': 'Eliminar clientes'},
{'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'},
]},
{'module': 'Taller', 'permissions': [
{'key': 'workshop.view', 'label': 'Ver órdenes'},
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes'},
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra'},
{'key': 'workshop.view', 'label': 'Ver módulo Taller'},
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'},
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'},
]},
{'module': 'Facturación', 'permissions': [
{'key': 'invoicing.view', 'label': 'Ver facturas'},
@@ -79,8 +130,9 @@ _AVAILABLE_PERMISSIONS = [
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
]},
{'module': 'Configuración', 'permissions': [
{'key': 'config.view', 'label': 'Ver configuración'},
{'key': 'config.edit', 'label': 'Editar configuración'},
{'key': 'config.edit_prices', 'label': 'Modificar precios'},
{'key': 'config.edit_prices', 'label': 'Modificar precios globales'},
]},
{'module': 'Contabilidad', 'permissions': [
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
@@ -115,6 +167,30 @@ def _get_role_permissions(conn, role):
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
def _get_workshop_permissions(conn, role):
"""Return configured workshop permissions for a role, falling back to defaults."""
cur = conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'")
row = cur.fetchone()
cur.close()
if row and row[0]:
try:
configured = json.loads(row[0])
if isinstance(configured, dict) and role in configured:
return configured[role]
except (ValueError, TypeError):
pass
return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []})
def _get_all_workshop_permissions(conn):
"""Return effective workshop permissions for every known role."""
result = {}
for role in _DEFAULT_WORKSHOP_PERMISSIONS:
result[role] = _get_workshop_permissions(conn, role)
return result
@config_bp.route('/branches', methods=['GET'])
@require_auth()
def list_branches():
@@ -444,6 +520,50 @@ def save_role_permissions_config():
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/role-permissions/workshop', methods=['GET'])
@require_auth('config.view')
def get_workshop_permissions_config():
"""Return the configured workshop permissions (statuses + actions) per role."""
conn = get_tenant_conn(g.tenant_id)
try:
return jsonify({
'roles': _get_all_workshop_permissions(conn),
'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES],
'actions': _WORKSHOP_ACTIONS,
})
finally:
conn.close()
@config_bp.route('/role-permissions/workshop', methods=['PUT'])
@require_auth('config.edit')
def save_workshop_permissions_config():
"""Save the workshop permission mapping per role."""
if g.employee_role not in ('owner', 'admin'):
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
data = request.get_json() or {}
if 'roles' not in data:
return jsonify({'error': 'roles object required'}), 400
for role, cfg in data['roles'].items():
if role not in _DEFAULT_WORKSHOP_PERMISSIONS:
return jsonify({'error': f'Invalid role: {role}'}), 400
if not isinstance(cfg, dict):
return jsonify({'error': f'config for {role} must be an object'}), 400
if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list):
return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (json.dumps(data['roles']),))
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
@require_auth('config.edit')
def update_employee(emp_id):