fix(workspace): required-inputs gate fires + manager bypass dialog
Two bugs: 1. Gate silently passed when step.recipe_node_id was NULL — happened to every WO-30057 step after this morning's clone delete (the FK ON DELETE SET NULL wiped the link). _fp_missing_required_step_inputs returned an empty recordset when node was None, so the gate had nothing to fail on and button_finish succeeded with zero audit. Fix: _fp_check_step_inputs_complete now treats NULL recipe_node_id as an explicit "no recipe link" hard block. Operator can't finish; manager bypass posts chatter audit. 2. No tablet UI for the manager bypass. The gate's bypass was a Python context flag — invisible from the JS layer, so managers were stuck behind the same hard error as operators. Fix: new /fp/workspace/finish_step endpoint returns structured errors (gate type, missing_prompts list, bypass_available bool). Server-side enforces manager group when bypass=True (can't trust the client). New FpFinishBlockDialog OWL modal renders: - Non-manager: Cancel + Record Inputs - Manager: Cancel + Record Inputs + ⚠ Bypass & Finish (audit) JobWorkspace.onFinishStep routes plain finishes through the new endpoint; signature-required steps still go through /fp/workspace/sign_off (separate gate). Added is_manager to /fp/workspace/load payload so the JS knows which dialog variant to render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -243,6 +243,13 @@ class FpWorkspaceController(http.Controller):
|
||||
],
|
||||
'required_certs': required_certs,
|
||||
'receivings': receivings_payload,
|
||||
# 2026-05-24 — is_manager surfaces to the JS so it can offer
|
||||
# the manager-bypass affordance (e.g. on the required-inputs
|
||||
# gate dialog). Server-side endpoints re-check the group
|
||||
# before honouring any bypass param — this is for UI only.
|
||||
'is_manager': env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
),
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
@@ -310,6 +317,77 @@ class FpWorkspaceController(http.Controller):
|
||||
'attachment_id': attachment_id,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/finish_step — structured-error finish with manager bypass
|
||||
# ======================================================================
|
||||
# Wraps step.button_finish with structured response shape so the
|
||||
# frontend can render the FpFinishBlockDialog (Cancel / Record /
|
||||
# Bypass) instead of a generic toast. Manager bypass requires the
|
||||
# caller to set bypass_required_inputs=True AND the server confirms
|
||||
# the user has the fusion_plating_manager group — trusting the
|
||||
# context flag alone would let any operator bypass via raw RPC.
|
||||
|
||||
@http.route('/fp/workspace/finish_step', type='jsonrpc', auth='user')
|
||||
def finish_step(self, step_id, bypass_required_inputs=False):
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
|
||||
# Server-side bypass authorization — can't trust the client.
|
||||
is_manager = env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
)
|
||||
if bypass_required_inputs and not is_manager:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'Manager privilege required to bypass required inputs.',
|
||||
'gate': 'permission_denied',
|
||||
'bypass_available': False,
|
||||
}
|
||||
|
||||
ctx = {}
|
||||
if bypass_required_inputs:
|
||||
ctx['fp_skip_required_inputs_gate'] = True
|
||||
|
||||
try:
|
||||
step.with_context(**ctx).button_finish()
|
||||
except UserError as e:
|
||||
msg = str(e.args[0]) if e.args else str(e)
|
||||
# Classify the gate so the JS knows whether to show the
|
||||
# block dialog (required_inputs) vs a plain toast.
|
||||
gate = 'other'
|
||||
if 'required input' in msg.lower() or 'no recipe link' in msg.lower():
|
||||
gate = 'required_inputs'
|
||||
elif 'sign-off' in msg.lower() or 'sign off' in msg.lower():
|
||||
gate = 'signoff'
|
||||
elif 'predecessor' in msg.lower():
|
||||
gate = 'predecessor'
|
||||
elif 'contract review' in msg.lower():
|
||||
gate = 'contract_review'
|
||||
# Collect the per-prompt list when the gate is required_inputs
|
||||
# AND the step has a recipe_node (orphan steps have nothing to list).
|
||||
missing = []
|
||||
if gate == 'required_inputs' and step.recipe_node_id:
|
||||
try:
|
||||
for p in step._fp_missing_required_step_inputs():
|
||||
missing.append({'id': p.id, 'name': p.name or ''})
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'ok': False,
|
||||
'error': msg,
|
||||
'gate': gate,
|
||||
'missing_prompts': missing,
|
||||
'orphan_step': not bool(step.recipe_node_id),
|
||||
'bypass_available': is_manager and gate == 'required_inputs',
|
||||
}
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/finish_step unexpected error")
|
||||
return {'ok': False, 'error': str(exc), 'gate': 'unexpected'}
|
||||
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||
# ======================================================================
|
||||
|
||||
Reference in New Issue
Block a user