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

@@ -33,45 +33,43 @@ from services.service_order_engine import (
update_status,
)
from tenant_db import get_tenant_conn
from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
# Roles allowed to view or edit service orders.
# Counter (mostrador) creates/edits; workshop/mechanic only view restricted info.
# Roles allowed to access the workshop module at all.
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
_WORKSHOP_EDIT_ROLES = {'owner', 'admin', 'counter', 'cashier'}
# Statuses that mechanics are not allowed to see (single shared mechanic account).
# 'autorizada' is intentionally NOT hidden because mechanics must work on authorized orders.
# Delivery/logistics statuses are hidden for both workshop and mechanic.
_MECHANIC_HIDDEN_STATUSES = {
'cotizada', 'por_autorizar', 'autorizacion_parcial',
'por_facturar', 'facturada',
'por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar'
}
# Statuses hidden for workshop accounts (delivery/logistics only).
_WORKSHOP_HIDDEN_STATUSES = {
'por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar'
}
def _can_view_workshop():
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
def _can_edit_workshop():
return g.employee_role in _WORKSHOP_EDIT_ROLES
def _get_ws_perms():
"""Return effective workshop permissions for the current user."""
conn = get_tenant_conn(g.tenant_id)
try:
return _get_workshop_permissions(conn, g.employee_role)
finally:
conn.close()
def _is_restricted_workshop_viewer():
return g.employee_role in ('workshop', 'mechanic')
def _visible_statuses():
if g.employee_role in ('owner', 'admin'):
return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses'])
return _get_ws_perms().get('statuses', [])
def _redact_order_for_mechanic(order):
"""Remove customer/commercial data from the order payload for mechanics."""
if not _is_restricted_workshop_viewer():
def _can_do_action(action):
if g.employee_role in ('owner', 'admin'):
return True
return action in _get_ws_perms().get('actions', [])
def _redact_order_for_restricted(order):
"""Remove customer/commercial data when the role cannot view sensitive data."""
if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'):
return order
sensitive = {
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
@@ -93,14 +91,14 @@ def _redact_order_for_mechanic(order):
if k in sensitive:
redacted[k] = redact_value(v)
elif k == 'items' and isinstance(v, list):
redacted[k] = [_redact_item_for_mechanic(it) for it in v]
redacted[k] = [_redact_item_for_restricted(it) for it in v]
else:
redacted[k] = v
return redacted
def _redact_item_for_mechanic(item):
"""Hide mechanic name from item rows for mechanics."""
def _redact_item_for_restricted(item):
"""Hide sensitive item rows for restricted viewers."""
item = dict(item)
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
if k in item:
@@ -118,14 +116,18 @@ def require_workshop_view(f):
return decorated
def require_workshop_edit(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_edit_workshop():
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
return f(*args, **kwargs)
return decorated
def require_workshop_action(action):
def decorator(f):
@wraps(f)
@require_auth()
def decorated(*args, **kwargs):
if not _can_view_workshop():
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
if not _can_do_action(action):
return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403
return f(*args, **kwargs)
return decorated
return decorator
@service_order_bp.route('', methods=['GET'])
@@ -149,12 +151,11 @@ def list_orders():
employee_id=employee_id, delivery_method=delivery_method,
is_direct=is_direct, q=q, page=page, per_page=per_page
)
if _is_restricted_workshop_viewer():
result['data'] = [_redact_order_for_mechanic(o) for o in result.get('data', [])]
hidden_statuses = _MECHANIC_HIDDEN_STATUSES if g.employee_role == 'mechanic' else _WORKSHOP_HIDDEN_STATUSES
if g.employee_role not in ('owner', 'admin'):
visible = _visible_statuses()
result['data'] = [
o for o in result.get('data', [])
if o.get('status') not in hidden_statuses
_redact_order_for_restricted(o) for o in result.get('data', [])
if o.get('status') in visible
]
return jsonify(result)
finally:
@@ -204,7 +205,7 @@ def create_order_from_pos():
@service_order_bp.route('', methods=['POST'])
@require_workshop_edit
@require_workshop_action('create_order')
def create_order():
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -244,28 +245,22 @@ def get_order(so_id):
order = get_service_order(conn, so_id)
if not order:
return jsonify({'error': 'Service order not found'}), 404
# Workshop/mechanic accounts cannot view delivery/logistics (and mechanic also commercial) statuses.
if g.employee_role == 'mechanic' and order.get('status') in _MECHANIC_HIDDEN_STATUSES:
if order.get('status') not in _visible_statuses():
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
if g.employee_role == 'workshop' and order.get('status') in _WORKSHOP_HIDDEN_STATUSES:
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
return jsonify(_redact_order_for_mechanic(order))
return jsonify(_redact_order_for_restricted(order))
finally:
conn.close()
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
@require_workshop_view
@require_workshop_action('edit_order')
def update_order(so_id):
data = request.get_json() or {}
# Full edit remains for owner/admin/counter/cashier.
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
if g.employee_role == 'mechanic':
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
if not data or any(k not in allowed for k in data.keys()):
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
elif not _can_edit_workshop():
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
ok = update_service_order(conn, so_id, data)
@@ -277,7 +272,7 @@ def update_order(so_id):
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
@require_workshop_edit
@require_workshop_action('delete_order')
def delete_order(so_id):
"""Soft-delete a service order. Restricted to owner/admin."""
if g.employee_role not in ('owner', 'admin'):
@@ -294,16 +289,13 @@ def delete_order(so_id):
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
@require_workshop_view
@require_workshop_action('change_status')
def change_status(so_id):
if not (_can_edit_workshop() or g.employee_role == 'mechanic'):
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
data = request.get_json() or {}
new_status = data.get('status')
if not new_status:
return jsonify({'error': 'status is required'}), 400
# Mechanics cannot move orders into commercial/closed statuses.
if g.employee_role == 'mechanic' and new_status in _MECHANIC_HIDDEN_STATUSES:
if new_status not in _visible_statuses():
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
conn = get_tenant_conn(g.tenant_id)
try:
@@ -322,7 +314,7 @@ def change_status(so_id):
# ─── Items (Parts) ─────────────────────────────
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
@require_workshop_edit
@require_workshop_action('add_items')
def add_order_item(so_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -334,7 +326,7 @@ def add_order_item(so_id):
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
@require_workshop_edit
@require_workshop_action('add_items')
def update_order_item(item_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -348,7 +340,7 @@ def update_order_item(item_id):
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_workshop_edit
@require_workshop_action('add_items')
def delete_order_item(item_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -361,7 +353,7 @@ def delete_order_item(item_id):
# ─── Labor ─────────────────────────────
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
@require_workshop_edit
@require_workshop_action('add_labor')
def add_order_labor(so_id):
data = request.get_json() or {}
if not data.get('description'):
@@ -375,7 +367,7 @@ def add_order_labor(so_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
@require_workshop_edit
@require_workshop_action('add_labor')
def update_order_labor(labor_id):
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
@@ -389,7 +381,7 @@ def update_order_labor(labor_id):
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
@require_workshop_edit
@require_workshop_action('add_labor')
def delete_order_labor(labor_id):
conn = get_tenant_conn(g.tenant_id)
try:
@@ -416,7 +408,7 @@ def kanban_summary():
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
@require_workshop_edit
@require_workshop_action('add_items')
def reserve_order_item(so_id, item_id):
"""Reserve inventory for a service order item."""
conn = get_tenant_conn(g.tenant_id)
@@ -430,7 +422,7 @@ def reserve_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
@require_workshop_edit
@require_workshop_action('add_items')
def release_order_item(so_id, item_id):
"""Release a previous inventory reservation."""
conn = get_tenant_conn(g.tenant_id)
@@ -448,6 +440,7 @@ def release_order_item(so_id, item_id):
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
@require_auth('pos.sell')
@require_workshop_action('convert_to_sale')
def convert_order_to_sale(so_id):
"""Convert a service order into a POS sale.
@@ -487,6 +480,7 @@ def convert_order_to_sale(so_id):
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
@require_auth('pos.remission')
@require_workshop_action('convert_to_remission')
def convert_order_to_remission(so_id):
"""Convert a service order into a counter remission note (pending payment).
@@ -517,7 +511,7 @@ def convert_order_to_remission(so_id):
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
@require_workshop_edit
@require_workshop_action('assign_mechanic')
def assign_mechanic_endpoint(so_id):
"""Assign a mechanic/technician to a service order."""
data = request.get_json() or {}
@@ -536,7 +530,7 @@ def assign_mechanic_endpoint(so_id):
@service_order_bp.route('/customers', methods=['POST'])
@require_workshop_edit
@require_workshop_action('edit_order')
def create_customer_for_workshop():
"""Create a customer directly from the workshop flow."""
data = request.get_json() or {}
@@ -570,7 +564,7 @@ def create_customer_for_workshop():
@service_order_bp.route('/vehicles', methods=['POST'])
@require_workshop_edit
@require_workshop_action('edit_order')
def create_vehicle_for_workshop():
"""Create a fleet vehicle directly from the workshop flow.
@@ -685,7 +679,7 @@ def list_catalog():
@service_order_bp.route('/service-catalog', methods=['POST'])
@require_workshop_edit
@require_workshop_action('edit_order')
def create_catalog_item():
"""Create a reusable labor concept."""
data = request.get_json() or {}
@@ -701,7 +695,7 @@ def create_catalog_item():
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
@require_workshop_edit
@require_workshop_action('edit_order')
def update_catalog_item(item_id):
"""Update a reusable labor concept."""
data = request.get_json() or {}
@@ -716,7 +710,7 @@ def update_catalog_item(item_id):
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
@require_workshop_edit
@require_workshop_action('edit_order')
def delete_catalog_item(item_id):
"""Soft-delete a reusable labor concept."""
conn = get_tenant_conn(g.tenant_id)
@@ -731,7 +725,7 @@ def delete_catalog_item(item_id):
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
@require_workshop_edit
@require_workshop_action('edit_order')
def print_service_order_ticket(so_id):
"""Generate a printable ticket for a service order.