fix(simple-editor): HTML in chatter + library form + expand per-step inline edit

Three fixes from user feedback:

1. Chatter posting raw HTML
   _AUDIT_BODY in migration 19.0.18.8.0 was a plain str with <p>
   tags. message_post escaped it for safety, so the chatter pill
   rendered '<p><strong>...</strong></p>' literally to the recipe
   author. Wrapped in markupsafe.Markup so Odoo recognises it as
   safe HTML. Going forward: ANY message_post body containing HTML
   tags MUST be wrapped in Markup() — most callers already do this,
   the migration script was the outlier.

2. Library template editor showed raw <p> tags
   onOpenLibraryEdit was JSON-cloning the payload directly without
   running description through the existing _htmlToText helper that
   the per-step editor uses. Added the conversion. Save path
   (onSaveLibraryEditor + library_save) already wraps via
   _textToHtml so storage stays HTML-compatible.

3. Per-step inline form was missing critical fields — user had to
   delete + re-add a step to change Type/workflow trigger/parallel/signoff
   onToggleEdit now also captures default_kind, triggers_workflow_state_id,
   parallel_start, requires_signoff into the edit state. onSaveStep
   sends them in the write vals. Added _fpResetStepEdit helper to
   keep open/cancel/save reset paths in sync.

   New per-step form has:
     * Step Type (Default Kind) dropdown — drives workflow milestone
       triggers + step-kind routing (e.g. contract_review opens QA-005)
     * Triggers Workflow State dropdown (Sub 14) — per-step override
     * Parallel Start checkbox (Sub 13)
     * Require QA Sign-off checkbox

   step_write controller endpoint also gained a field whitelist —
   was previously accepting any vals dict from the client (security
   hole + opaque to maintainers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-04 00:24:40 -04:00
parent d6bd43b76e
commit 3cc393454d
5 changed files with 159 additions and 14 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.18.12.3', 'version': '19.0.18.12.4',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -102,6 +102,15 @@ class SimpleRecipeController(http.Controller):
'work_center_id': step.work_center_id.id if step.work_center_id else False, 'work_center_id': step.work_center_id.id if step.work_center_id else False,
'source_template_id': step.source_template_id.id or False, 'source_template_id': step.source_template_id.id or False,
'collect_measurements': bool(step.collect_measurements), 'collect_measurements': bool(step.collect_measurements),
# Sub 13 — per-step opt-out of the sequential gate
'parallel_start': bool(step.parallel_start),
# Sub 14 — workflow milestone trigger override
'triggers_workflow_state_id': (
step.triggers_workflow_state_id.id
if 'triggers_workflow_state_id' in step._fields
and step.triggers_workflow_state_id
else False
),
'measurements_badge_text': badge_text, 'measurements_badge_text': badge_text,
'measurements_badge_class': badge_class, 'measurements_badge_class': badge_class,
'inputs': [ 'inputs': [
@@ -437,8 +446,29 @@ class SimpleRecipeController(http.Controller):
@http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user') @http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user')
def step_write(self, node_id, vals): def step_write(self, node_id, vals):
node = request.env['fusion.plating.process.node'].browse(node_id) """Update fields on an existing recipe step (operation node).
node.write(vals)
Whitelisted to the fields the inline edit panel actually surfaces
— never trust client-provided node_type / parent_id / etc.
"""
node = request.env['fusion.plating.process.node'].browse(int(node_id))
if not node.exists():
return {'ok': False, 'error': 'not_found'}
node.check_access('write')
allowed = {
'name', 'description', 'icon',
'default_kind',
'requires_signoff', 'requires_predecessor_done',
'parallel_start', # Sub 13
'triggers_workflow_state_id', # Sub 14
'requires_rack_assignment',
'requires_transition_form',
'estimated_duration',
'collect_measurements',
}
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
if clean:
node.write(clean)
return {'ok': True} return {'ok': True}
@http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user') @http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user')

View File

@@ -27,10 +27,15 @@ have a paper trail.
import logging import logging
from markupsafe import Markup
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_AUDIT_BODY = ( # Wrapped in Markup so Odoo's message_post recognises it as safe HTML
# instead of escaping the tags. Without Markup, the chatter pill renders
# the raw "<p><strong>...</strong></p>" string literally to the operator.
_AUDIT_BODY = Markup(
'<p><strong>Recipe migrated to v19.0.18.8.0 step layout.</strong></p>' '<p><strong>Recipe migrated to v19.0.18.8.0 step layout.</strong></p>'
'<p>Step nodes that were direct children of this recipe (Simple ' '<p>Step nodes that were direct children of this recipe (Simple '
'Editor authoring) have been promoted to operation nodes so they ' 'Editor authoring) have been promoted to operation nodes so they '

View File

@@ -44,6 +44,12 @@ export class FpSimpleRecipeEditor extends Component {
editingStepId: null, editingStepId: null,
editName: "", editName: "",
editInstructions: "", editInstructions: "",
// Sub 14 + Sub 13 — additional per-step settings that the
// user can change inline without delete + re-add.
editDefaultKind: "",
editTriggersWorkflowStateId: false,
editParallelStart: false,
editRequiresSignoff: false,
// Inline library form — open when authoring or editing a // Inline library form — open when authoring or editing a
// library template directly from the right pane. null = // library template directly from the right pane. null =
// closed; otherwise carries the template payload. // closed; otherwise carries the template payload.
@@ -287,6 +293,13 @@ export class FpSimpleRecipeEditor extends Component {
// we want to be able to mutate this.state.libraryEditor.* in // we want to be able to mutate this.state.libraryEditor.* in
// place without triggering library list re-renders. // place without triggering library list re-renders.
this.state.libraryEditor = JSON.parse(JSON.stringify(data.template)); this.state.libraryEditor = JSON.parse(JSON.stringify(data.template));
// description is fields.Html on the server → strip <p> tags
// for clean display in the textarea (operators don't want
// to see raw HTML markup). Saved back via _textToHtml on
// onSaveLibraryEditor below.
this.state.libraryEditor.description = this._htmlToText(
this.state.libraryEditor.description || ""
);
} else { } else {
this.notification.add( this.notification.add(
_t("Could not load library template — it may have been deleted."), _t("Could not load library template — it may have been deleted."),
@@ -560,18 +573,35 @@ export class FpSimpleRecipeEditor extends Component {
* Save discards changes — operator-style "I clicked the wrong row" * Save discards changes — operator-style "I clicked the wrong row"
* shouldn't write garbage to the recipe. * shouldn't write garbage to the recipe.
*/ */
onToggleEdit(stepId) { async onToggleEdit(stepId) {
if (this.state.editingStepId === stepId) { if (this.state.editingStepId === stepId) {
this.state.editingStepId = null; this._fpResetStepEdit();
this.state.editName = "";
this.state.editInstructions = "";
return; return;
} }
const step = this.state.steps.find((s) => s.id === stepId); const step = this.state.steps.find((s) => s.id === stepId);
if (!step) return; if (!step) return;
// Sub 14 — make sure the workflow-state catalog is cached so
// the dropdown in the inline form has options to render.
await this._fpEnsureWorkflowStatesLoaded();
this.state.editingStepId = stepId; this.state.editingStepId = stepId;
this.state.editName = step.name || ""; this.state.editName = step.name || "";
this.state.editInstructions = this._htmlToText(step.description || ""); this.state.editInstructions = this._htmlToText(step.description || "");
// Settings the user can now change WITHOUT delete + re-add.
this.state.editDefaultKind = step.default_kind || "";
this.state.editTriggersWorkflowStateId =
step.triggers_workflow_state_id || false;
this.state.editParallelStart = !!step.parallel_start;
this.state.editRequiresSignoff = !!step.requires_signoff;
}
_fpResetStepEdit() {
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
this.state.editDefaultKind = "";
this.state.editTriggersWorkflowStateId = false;
this.state.editParallelStart = false;
this.state.editRequiresSignoff = false;
} }
async onSaveStep() { async onSaveStep() {
@@ -580,22 +610,25 @@ export class FpSimpleRecipeEditor extends Component {
const vals = { const vals = {
name: this.state.editName || _t("Untitled Step"), name: this.state.editName || _t("Untitled Step"),
description: this._textToHtml(this.state.editInstructions), description: this._textToHtml(this.state.editInstructions),
// New per-step settings — user can flip these without
// deleting and re-adding the step.
default_kind: this.state.editDefaultKind || false,
triggers_workflow_state_id:
this.state.editTriggersWorkflowStateId || false,
parallel_start: !!this.state.editParallelStart,
requires_signoff: !!this.state.editRequiresSignoff,
}; };
await rpc("/fp/simple_recipe/step/write", { await rpc("/fp/simple_recipe/step/write", {
node_id: stepId, node_id: stepId,
vals: vals, vals: vals,
}); });
this.state.editingStepId = null; this._fpResetStepEdit();
this.state.editName = "";
this.state.editInstructions = "";
await this.loadAll(); await this.loadAll();
this.notification.add(_t("Step updated"), { type: "success" }); this.notification.add(_t("Step updated"), { type: "success" });
} }
onCancelEdit() { onCancelEdit() {
this.state.editingStepId = null; this._fpResetStepEdit();
this.state.editName = "";
this.state.editInstructions = "";
} }
// -------------------- Sub 12d — measurements config -------------------- // -------------------- Sub 12d — measurements config --------------------

View File

@@ -116,6 +116,83 @@
Shown to operators when running this step at the tank. Use line breaks for separate points. Shown to operators when running this step at the tank. Use line breaks for separate points.
</p> </p>
</div> </div>
<!-- Sub 14 + Sub 13 — settings the user can change
without delete + re-add. Step Type drives the
default-kind workflow trigger; the dropdown
below it lets them override per-step. -->
<div class="o_fp_edit_row" style="display: flex; gap: 16px; flex-wrap: wrap;">
<div class="o_fp_edit_field" style="flex: 1; min-width: 240px;">
<label>Step Type (Default Kind)</label>
<select class="form-select"
t-on-change="(ev) => { state.editDefaultKind = ev.target.value; }">
<option value="" t-att-selected="!state.editDefaultKind">— Generic —</option>
<option value="receiving" t-att-selected="state.editDefaultKind === 'receiving'">Receiving / Incoming Inspection</option>
<option value="contract_review" t-att-selected="state.editDefaultKind === 'contract_review'">Contract Review (QA-005)</option>
<option value="racking" t-att-selected="state.editDefaultKind === 'racking'">Racking</option>
<option value="mask" t-att-selected="state.editDefaultKind === 'mask'">Masking</option>
<option value="cleaning" t-att-selected="state.editDefaultKind === 'cleaning'">Cleaning</option>
<option value="electroclean" t-att-selected="state.editDefaultKind === 'electroclean'">Electroclean</option>
<option value="etch" t-att-selected="state.editDefaultKind === 'etch'">Etch / Activation</option>
<option value="rinse" t-att-selected="state.editDefaultKind === 'rinse'">Rinse</option>
<option value="strike" t-att-selected="state.editDefaultKind === 'strike'">Strike</option>
<option value="plate" t-att-selected="state.editDefaultKind === 'plate'">Plating</option>
<option value="replenishment" t-att-selected="state.editDefaultKind === 'replenishment'">Tank Replenishment</option>
<option value="wbf_test" t-att-selected="state.editDefaultKind === 'wbf_test'">Water Break Free Test</option>
<option value="dry" t-att-selected="state.editDefaultKind === 'dry'">Drying</option>
<option value="bake" t-att-selected="state.editDefaultKind === 'bake'">Bake</option>
<option value="demask" t-att-selected="state.editDefaultKind === 'demask'">De-Masking</option>
<option value="derack" t-att-selected="state.editDefaultKind === 'derack'">De-Racking</option>
<option value="inspect" t-att-selected="state.editDefaultKind === 'inspect'">Inspection</option>
<option value="final_inspect" t-att-selected="state.editDefaultKind === 'final_inspect'">Final Inspection</option>
<option value="ship" t-att-selected="state.editDefaultKind === 'ship'">Shipping</option>
</select>
<p class="o_fp_edit_hint">
Drives workflow milestone triggers (e.g. <code>final_inspect</code> fires
the Inspected status) and routing (e.g. <code>contract_review</code> opens
QA-005 instead of the input wizard).
</p>
</div>
<div class="o_fp_edit_field"
style="flex: 1; min-width: 240px;"
t-if="state.workflowStates and state.workflowStates.length">
<label>Triggers Workflow State</label>
<select class="form-select"
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
<option value="" t-att-selected="!state.editTriggersWorkflowStateId">— None (use Step Type) —</option>
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
<option t-att-value="ws.id"
t-att-selected="state.editTriggersWorkflowStateId === ws.id"
t-esc="ws.name"/>
</t>
</select>
<p class="o_fp_edit_hint">
Override the default-kind matching. Wins over Step Type when set.
</p>
</div>
</div>
<div class="o_fp_edit_field">
<label class="me-3">
<input type="checkbox"
t-model="state.editParallelStart"/>
<strong> Parallel Start</strong>
<small class="text-muted ms-2">
Lets the step start while earlier-sequence steps are still in progress
(overrides the recipe's sequential gate for this step only).
</small>
</label>
<br/>
<label class="me-3">
<input type="checkbox"
t-model="state.editRequiresSignoff"/>
<strong> Require QA Sign-off</strong>
<small class="text-muted ms-2">
Marks this step as needing a QA signature before it can be considered done.
</small>
</label>
</div>
<!-- Sub 12d — Measurements config --> <!-- Sub 12d — Measurements config -->
<div class="o_fp_edit_field o_fp_measurements_config"> <div class="o_fp_edit_field o_fp_measurements_config">
<label> <label>