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',
'version': '19.0.18.12.3',
'version': '19.0.18.12.4',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'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,
'source_template_id': step.source_template_id.id or False,
'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_class': badge_class,
'inputs': [
@@ -437,8 +446,29 @@ class SimpleRecipeController(http.Controller):
@http.route('/fp/simple_recipe/step/write', type='jsonrpc', auth='user')
def step_write(self, node_id, vals):
node = request.env['fusion.plating.process.node'].browse(node_id)
node.write(vals)
"""Update fields on an existing recipe step (operation node).
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}
@http.route('/fp/simple_recipe/step/remove', type='jsonrpc', auth='user')

View File

@@ -27,10 +27,15 @@ have a paper trail.
import logging
from markupsafe import Markup
_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>Step nodes that were direct children of this recipe (Simple '
'Editor authoring) have been promoted to operation nodes so they '

View File

@@ -44,6 +44,12 @@ export class FpSimpleRecipeEditor extends Component {
editingStepId: null,
editName: "",
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
// library template directly from the right pane. null =
// 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
// place without triggering library list re-renders.
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 {
this.notification.add(
_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"
* shouldn't write garbage to the recipe.
*/
onToggleEdit(stepId) {
async onToggleEdit(stepId) {
if (this.state.editingStepId === stepId) {
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
this._fpResetStepEdit();
return;
}
const step = this.state.steps.find((s) => s.id === stepId);
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.editName = step.name || "";
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() {
@@ -580,22 +610,25 @@ export class FpSimpleRecipeEditor extends Component {
const vals = {
name: this.state.editName || _t("Untitled Step"),
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", {
node_id: stepId,
vals: vals,
});
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
this._fpResetStepEdit();
await this.loadAll();
this.notification.add(_t("Step updated"), { type: "success" });
}
onCancelEdit() {
this.state.editingStepId = null;
this.state.editName = "";
this.state.editInstructions = "";
this._fpResetStepEdit();
}
// -------------------- 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.
</p>
</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 -->
<div class="o_fp_edit_field o_fp_measurements_config">
<label>