From 47ede238a3db5b5e9553ee22744b1044f737bcd2 Mon Sep 17 00:00:00 2001
From: Dan Kendall
Date: Sat, 16 May 2026 15:39:35 -0400
Subject: [PATCH 1/9] [ADD] web_field_provenance: SAP-style provenance badge +
ORM dirty bit
---
web_field_provenance/README.rst | 172 ++++++
web_field_provenance/__init__.py | 1 +
web_field_provenance/__manifest__.py | 24 +
web_field_provenance/models/__init__.py | 2 +
web_field_provenance/models/base_model.py | 150 +++++
.../models/ir_model_fields.py | 14 +
web_field_provenance/pyproject.toml | 3 +
web_field_provenance/readme/CONTRIBUTORS.md | 1 +
web_field_provenance/readme/DESCRIPTION.md | 20 +
web_field_provenance/readme/ROADMAP.md | 15 +
web_field_provenance/readme/USAGE.md | 43 ++
.../static/description/index.html | 513 ++++++++++++++++++
.../static/src/provenance_badge.esm.js | 63 +++
.../static/src/provenance_badge.scss | 25 +
.../static/src/provenance_badge.xml | 22 +
web_field_provenance/tests/__init__.py | 1 +
web_field_provenance/tests/test_provenance.py | 62 +++
.../views/ir_model_fields_views.xml | 13 +
18 files changed, 1144 insertions(+)
create mode 100644 web_field_provenance/README.rst
create mode 100644 web_field_provenance/__init__.py
create mode 100644 web_field_provenance/__manifest__.py
create mode 100644 web_field_provenance/models/__init__.py
create mode 100644 web_field_provenance/models/base_model.py
create mode 100644 web_field_provenance/models/ir_model_fields.py
create mode 100644 web_field_provenance/pyproject.toml
create mode 100644 web_field_provenance/readme/CONTRIBUTORS.md
create mode 100644 web_field_provenance/readme/DESCRIPTION.md
create mode 100644 web_field_provenance/readme/ROADMAP.md
create mode 100644 web_field_provenance/readme/USAGE.md
create mode 100644 web_field_provenance/static/description/index.html
create mode 100644 web_field_provenance/static/src/provenance_badge.esm.js
create mode 100644 web_field_provenance/static/src/provenance_badge.scss
create mode 100644 web_field_provenance/static/src/provenance_badge.xml
create mode 100644 web_field_provenance/tests/__init__.py
create mode 100644 web_field_provenance/tests/test_provenance.py
create mode 100644 web_field_provenance/views/ir_model_fields_views.xml
diff --git a/web_field_provenance/README.rst b/web_field_provenance/README.rst
new file mode 100644
index 000000000000..a53750a70109
--- /dev/null
+++ b/web_field_provenance/README.rst
@@ -0,0 +1,172 @@
+====================
+Web Field Provenance
+====================
+
+..
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! source digest: sha256:c0e6a57f9f1fb3302bcfc4940b464ae29fdfd5116dc0cefc8e257efcb4370316
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
+ :target: https://odoo-community.org/page/development-status
+ :alt: Beta
+.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-3
+.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github
+ :target: https://github.com/OCA/web/tree/18.0/web_field_provenance
+ :alt: OCA/web
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+ :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_field_provenance
+ :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
+ :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0
+ :alt: Try me on Runboat
+
+|badge1| |badge2| |badge3| |badge4| |badge5|
+
+Track and surface whether a field value was set by the user or assigned
+by a computed/derived default — Salesforce/SAP-style field provenance
+for Odoo.
+
+Solves the OCA-wide bug class where
+``compute=..., store=True, readonly=False`` ("computed-writable") fields
+silently overwrite manual user overrides when an upstream dependency
+changes and the compute re-fires through ``super()``. Provides:
+
+1. A persistent per-record provenance map (``_provenance``) that records
+ which fields were last set by a user vs. assigned by a compute.
+2. A reusable ``_user_set(fname)`` helper for compute methods to gate
+ ``super()`` on real user intent rather than chain-residual values.
+3. An OWL ``provenance_m2o`` widget that renders a small badge next to
+ opted-in fields: green gear icon = derived default, pencil = user
+ override. Clicking the badge anchors the value as user-supplied.
+
+This module is plumbing. To surface the badge on a specific field, set
+``track_provenance=True`` on its ``ir.model.fields`` record and replace
+the view tag with ``widget="provenance_m2o"``.
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Usage
+=====
+
+1. Opt-in a field for provenance tracking
+-----------------------------------------
+
+In Settings → Technical → Database Structure → Fields, find the field
+you want tracked and tick **Track Provenance**. Or programmatically:
+
+.. code:: python
+
+ self.env["ir.model.fields"]._get("account.move", "invoice_payment_term_id")\
+ .write({"track_provenance": True})
+
+2. Preserve user values inside an override compute
+--------------------------------------------------
+
+In modules that layer overrides on Odoo core computes (e.g.
+``sale_order_type``), guard the recompute on the new helper:
+
+.. code:: python
+
+ @api.depends("sale_type_id")
+ def _compute_invoice_payment_term_id(self):
+ # Records the user has explicitly touched are excluded from super().
+ preserved = self.filtered(
+ lambda m: m._user_set("invoice_payment_term_id"),
+ )
+ super(AccountMove, self - preserved)._compute_invoice_payment_term_id()
+ for move in (self - preserved).filtered("sale_type_id.payment_term_id"):
+ move.invoice_payment_term_id = move.sale_type_id.payment_term_id
+
+3. Render the badge in views
+----------------------------
+
+Replace the field's view tag:
+
+.. code:: xml
+
+
+
+A small icon appears next to the field:
+
+- **Green gear** — the value came from a default or compute. It may
+ change if upstream fields change.
+- **Pencil** — the user has anchored this value. Recompute cascades will
+ respect it.
+
+Clicking the icon promotes the current value to user-anchored.
+
+Known issues / Roadmap
+======================
+
+- ☐ **provenance_selection** and **provenance_char** widget variants
+ (current MVP only ships ``provenance_m2o``).
+- ☐ Pre-commit cascade banner: intercept the onchange RPC result in the
+ OWL form controller and surface "Field X is about to change from A → B
+ because Y changed" before commit. ProseMirror ``filterTransaction`` is
+ the conceptual reference.
+- ☐ Draft-time cascade log panel (chatter-adjacent) listing every field
+ cascade with timestamp, prior value, new value, cause field.
+- ☐ Undo toast (Linear-style) — let the cascade happen, then offer a
+ 6-second window to revert just the overwritten field.
+- ☐ Demo addon ``web_field_provenance_sale`` that wires ``sale.order`` +
+ ``account.move`` fields touched by ``sale_order_type`` to the widget
+ and applies the ``_user_set`` guard in the seven mirror computes.
+- ☐ Upstream RFC to Odoo S.A.: expose ``_user_set`` on the ORM base
+ class so this module becomes plumbing-only.
+
+Bug Tracker
+===========
+
+Bugs are tracked on `GitHub Issues `_.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us to smash it by providing a detailed and welcomed
+`feedback `_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+-------
+
+* Ledoweb
+
+Contributors
+------------
+
+- Dan Kendall dkendall@ledoweb.com
+
+Maintainers
+-----------
+
+This module is maintained by the OCA.
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+.. |maintainer-dkendall| image:: https://github.com/dkendall.png?size=40px
+ :target: https://github.com/dkendall
+ :alt: dkendall
+
+Current `maintainer `__:
+
+|maintainer-dkendall|
+
+This module is part of the `OCA/web `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/web_field_provenance/__init__.py b/web_field_provenance/__init__.py
new file mode 100644
index 000000000000..0650744f6bc6
--- /dev/null
+++ b/web_field_provenance/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/web_field_provenance/__manifest__.py b/web_field_provenance/__manifest__.py
new file mode 100644
index 000000000000..cd0edb03560d
--- /dev/null
+++ b/web_field_provenance/__manifest__.py
@@ -0,0 +1,24 @@
+# Copyright 2026 Ledoweb (Dan Kendall)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+{
+ "name": "Web Field Provenance",
+ "summary": "Track and surface whether a field value came from the user "
+ "or from a computed/derived default, SAP-style.",
+ "category": "Tools",
+ "version": "18.0.1.0.0",
+ "author": "Ledoweb, Odoo Community Association (OCA)",
+ "license": "AGPL-3",
+ "website": "https://github.com/OCA/web",
+ "depends": ["web"],
+ "data": [
+ "views/ir_model_fields_views.xml",
+ ],
+ "assets": {
+ "web.assets_backend": [
+ "web_field_provenance/static/src/**/*",
+ ],
+ },
+ "maintainers": ["dkendall"],
+ "auto_install": False,
+ "installable": True,
+}
diff --git a/web_field_provenance/models/__init__.py b/web_field_provenance/models/__init__.py
new file mode 100644
index 000000000000..89ee9153d32b
--- /dev/null
+++ b/web_field_provenance/models/__init__.py
@@ -0,0 +1,2 @@
+from . import base_model
+from . import ir_model_fields
diff --git a/web_field_provenance/models/base_model.py b/web_field_provenance/models/base_model.py
new file mode 100644
index 000000000000..8396f925d386
--- /dev/null
+++ b/web_field_provenance/models/base_model.py
@@ -0,0 +1,150 @@
+# Copyright 2026 Ledoweb (Dan Kendall)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+"""Provenance machinery for computed-writable fields.
+
+Two ideas implemented here:
+
+1. Per-field "did the user set this?" probe — `_user_set(fname)` — based on
+ `record._origin`. Cheap, no schema cost, available to every model.
+
+2. Per-field provenance map — a transient JSON column `_provenance` that
+ records which fields were last written by a user vs. assigned by a
+ compute. Only populated for fields whose `ir.model.fields.track_provenance`
+ flag is set. The web client reads this map and renders a small badge
+ next to opted-in fields (system gear vs. user pencil), Salesforce/SAP
+ style.
+
+The two paths cooperate: in compute methods, callers can guard with
+`if self._user_set('payment_term_id'): return` to preserve manual values
+during recompute cascades. The provenance map then lights up the badge so
+the user sees the system gave way to their choice.
+"""
+
+from odoo import api, fields, models
+
+# Marker key for provenance values. Kept short to keep the JSON column
+# compact when many fields opt in.
+_USER = "u"
+_SYSTEM = "s"
+
+
+class Base(models.AbstractModel):
+ _inherit = "base"
+
+ _provenance = fields.Json(
+ string="Field Provenance",
+ help="Per-field provenance map: {field_name: 'u'|'s'}. "
+ "'u' = user-supplied, 's' = system-assigned (compute/default).",
+ copy=False,
+ )
+
+ # ------------------------------------------------------------------
+ # Helper API consumed by computes / wizards / tests
+ # ------------------------------------------------------------------
+ def _user_set(self, fname):
+ """Return True if `fname` was set by the user, not by a compute.
+
+ Resolution order:
+ 1. If a provenance map exists and records 'u' for this field,
+ trust it (explicit, persisted).
+ 2. Else fall back to `_origin` comparison: if the current value
+ differs from the DB-saved value AND is non-falsy, treat as
+ user-touched (transactional dirty bit).
+ 3. Otherwise False.
+
+ The compute-cascade-preserve pattern looks like:
+
+ @api.depends("sale_type_id")
+ def _compute_invoice_payment_term_id(self):
+ preserved = self.filtered(
+ lambda m: m._user_set("invoice_payment_term_id"),
+ )
+ super(AccountMove, self - preserved)._compute_invoice_payment_term_id()
+ """
+ self.ensure_one()
+ prov = self._provenance or {}
+ if prov.get(fname) == _USER:
+ return True
+ origin = self._origin
+ if origin and origin[fname] and self[fname] != origin[fname]:
+ return True
+ return False
+
+ # ------------------------------------------------------------------
+ # ORM hooks — stamp provenance on writes
+ # ------------------------------------------------------------------
+ @api.model_create_multi
+ def create(self, vals_list):
+ records = super().create(vals_list)
+ records._stamp_provenance_from_vals(vals_list, source=_USER)
+ return records
+
+ def write(self, vals):
+ # Snapshot incoming user-driven field names BEFORE write so we
+ # can distinguish them from cascade-recompute writes performed
+ # inside super().write() via the ORM's compute pass.
+ user_keys = [k for k in vals if self._field_tracks_provenance(k)]
+ res = super().write(vals)
+ if user_keys:
+ self._stamp_provenance_keys(user_keys, source=_USER)
+ return res
+
+ # ------------------------------------------------------------------
+ # Internals
+ # ------------------------------------------------------------------
+ def _field_tracks_provenance(self, fname):
+ """Cheap cache: is this field opted in via ir.model.fields.track_provenance?"""
+ if fname.startswith("_") or fname not in self._fields:
+ return False
+ # Lookup is bound to the registry — avoid round-trip on every write.
+ return bool(
+ self.env["ir.model.fields"]._get(self._name, fname).track_provenance
+ )
+
+ def _stamp_provenance_from_vals(self, vals_list, source):
+ for rec, vals in zip(self, vals_list, strict=False):
+ keys = [k for k in (vals or {}) if rec._field_tracks_provenance(k)]
+ if keys:
+ rec._stamp_provenance_keys(keys, source=source)
+
+ def _stamp_provenance_keys(self, keys, source):
+ for rec in self:
+ current = dict(rec._provenance or {})
+ for k in keys:
+ current[k] = source
+ # Bypass the public write() to avoid infinite recursion.
+ rec.sudo().with_context(prevent_provenance_stamp=True)._write(
+ {
+ "_provenance": current,
+ }
+ )
+
+ # ------------------------------------------------------------------
+ # Expose provenance to the web client via read()
+ # ------------------------------------------------------------------
+ def read(self, fields=None, load="_classic_read"):
+ """Inject `_provenance` into reads if the client asked for any
+ tracked field. The OWL widget consumes this map to render a badge.
+ """
+ result = super().read(fields=fields, load=load)
+ if not result:
+ return result
+ wants_provenance = fields is None or any(
+ f != "_provenance" and self._field_tracks_provenance(f) for f in fields
+ )
+ if not wants_provenance:
+ return result
+ # Re-fetch _provenance for every read row we didn't already include.
+ if fields is not None and "_provenance" not in fields:
+ ids = [r["id"] for r in result]
+ extra = {
+ r["id"]: r["_provenance"]
+ for r in super().read(
+ fields=["_provenance"],
+ load=load,
+ )
+ if r["id"] in ids
+ }
+ for row in result:
+ row["_provenance"] = extra.get(row["id"]) or {}
+ return result
diff --git a/web_field_provenance/models/ir_model_fields.py b/web_field_provenance/models/ir_model_fields.py
new file mode 100644
index 000000000000..fce60ed576c1
--- /dev/null
+++ b/web_field_provenance/models/ir_model_fields.py
@@ -0,0 +1,14 @@
+# Copyright 2026 Ledoweb (Dan Kendall)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+from odoo import fields, models
+
+
+class IrModelFields(models.Model):
+ _inherit = "ir.model.fields"
+
+ track_provenance = fields.Boolean(
+ help="Surface a badge on the web client showing whether the value "
+ "was set by the user or assigned by a computed default. "
+ "Only meaningful on computed-writable fields "
+ "(compute=..., store=True, readonly=False).",
+ )
diff --git a/web_field_provenance/pyproject.toml b/web_field_provenance/pyproject.toml
new file mode 100644
index 000000000000..4231d0cccb3d
--- /dev/null
+++ b/web_field_provenance/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/web_field_provenance/readme/CONTRIBUTORS.md b/web_field_provenance/readme/CONTRIBUTORS.md
new file mode 100644
index 000000000000..11a61dd91cd4
--- /dev/null
+++ b/web_field_provenance/readme/CONTRIBUTORS.md
@@ -0,0 +1 @@
+- Dan Kendall
diff --git a/web_field_provenance/readme/DESCRIPTION.md b/web_field_provenance/readme/DESCRIPTION.md
new file mode 100644
index 000000000000..6f0900d7bed3
--- /dev/null
+++ b/web_field_provenance/readme/DESCRIPTION.md
@@ -0,0 +1,20 @@
+Track and surface whether a field value was set by the user or assigned
+by a computed/derived default — Salesforce/SAP-style field provenance for
+Odoo.
+
+Solves the OCA-wide bug class where `compute=..., store=True, readonly=False`
+("computed-writable") fields silently overwrite manual user overrides
+when an upstream dependency changes and the compute re-fires through
+`super()`. Provides:
+
+1. A persistent per-record provenance map (`_provenance`) that records
+ which fields were last set by a user vs. assigned by a compute.
+2. A reusable `_user_set(fname)` helper for compute methods to gate
+ `super()` on real user intent rather than chain-residual values.
+3. An OWL `provenance_m2o` widget that renders a small badge next to
+ opted-in fields: green gear icon = derived default, pencil = user
+ override. Clicking the badge anchors the value as user-supplied.
+
+This module is plumbing. To surface the badge on a specific field, set
+`track_provenance=True` on its `ir.model.fields` record and replace the
+view tag with `widget="provenance_m2o"`.
diff --git a/web_field_provenance/readme/ROADMAP.md b/web_field_provenance/readme/ROADMAP.md
new file mode 100644
index 000000000000..22aae6aa7767
--- /dev/null
+++ b/web_field_provenance/readme/ROADMAP.md
@@ -0,0 +1,15 @@
+- [ ] **provenance_selection** and **provenance_char** widget variants
+ (current MVP only ships `provenance_m2o`).
+- [ ] Pre-commit cascade banner: intercept the onchange RPC result in
+ the OWL form controller and surface "Field X is about to change from
+ A → B because Y changed" before commit. ProseMirror `filterTransaction`
+ is the conceptual reference.
+- [ ] Draft-time cascade log panel (chatter-adjacent) listing every
+ field cascade with timestamp, prior value, new value, cause field.
+- [ ] Undo toast (Linear-style) — let the cascade happen, then offer
+ a 6-second window to revert just the overwritten field.
+- [ ] Demo addon `web_field_provenance_sale` that wires `sale.order` +
+ `account.move` fields touched by `sale_order_type` to the widget and
+ applies the `_user_set` guard in the seven mirror computes.
+- [ ] Upstream RFC to Odoo S.A.: expose `_user_set` on the ORM base
+ class so this module becomes plumbing-only.
diff --git a/web_field_provenance/readme/USAGE.md b/web_field_provenance/readme/USAGE.md
new file mode 100644
index 000000000000..856688e921bc
--- /dev/null
+++ b/web_field_provenance/readme/USAGE.md
@@ -0,0 +1,43 @@
+## 1. Opt-in a field for provenance tracking
+
+In Settings → Technical → Database Structure → Fields, find the field you
+want tracked and tick **Track Provenance**. Or programmatically:
+
+```python
+self.env["ir.model.fields"]._get("account.move", "invoice_payment_term_id")\
+ .write({"track_provenance": True})
+```
+
+## 2. Preserve user values inside an override compute
+
+In modules that layer overrides on Odoo core computes (e.g.
+`sale_order_type`), guard the recompute on the new helper:
+
+```python
+@api.depends("sale_type_id")
+def _compute_invoice_payment_term_id(self):
+ # Records the user has explicitly touched are excluded from super().
+ preserved = self.filtered(
+ lambda m: m._user_set("invoice_payment_term_id"),
+ )
+ super(AccountMove, self - preserved)._compute_invoice_payment_term_id()
+ for move in (self - preserved).filtered("sale_type_id.payment_term_id"):
+ move.invoice_payment_term_id = move.sale_type_id.payment_term_id
+```
+
+## 3. Render the badge in views
+
+Replace the field's view tag:
+
+```xml
+
+```
+
+A small icon appears next to the field:
+
+* **Green gear** — the value came from a default or compute. It may
+ change if upstream fields change.
+* **Pencil** — the user has anchored this value. Recompute cascades
+ will respect it.
+
+Clicking the icon promotes the current value to user-anchored.
diff --git a/web_field_provenance/static/description/index.html b/web_field_provenance/static/description/index.html
new file mode 100644
index 000000000000..1a429443de29
--- /dev/null
+++ b/web_field_provenance/static/description/index.html
@@ -0,0 +1,513 @@
+
+
+
+
+
+Web Field Provenance
+
+
+
+
+
Web Field Provenance
+
+
+
+
Track and surface whether a field value was set by the user or assigned
+by a computed/derived default — Salesforce/SAP-style field provenance
+for Odoo.
+
Solves the OCA-wide bug class where
+compute=..., store=True, readonly=False (“computed-writable”) fields
+silently overwrite manual user overrides when an upstream dependency
+changes and the compute re-fires through super(). Provides:
+
+
A persistent per-record provenance map (_provenance) that records
+which fields were last set by a user vs. assigned by a compute.
+
A reusable _user_set(fname) helper for compute methods to gate
+super() on real user intent rather than chain-residual values.
+
An OWL provenance_m2o widget that renders a small badge next to
+opted-in fields: green gear icon = derived default, pencil = user
+override. Clicking the badge anchors the value as user-supplied.
+
+
This module is plumbing. To surface the badge on a specific field, set
+track_provenance=True on its ir.model.fields record and replace
+the view tag with widget="provenance_m2o".
In modules that layer overrides on Odoo core computes (e.g.
+sale_order_type), guard the recompute on the new helper:
+
+@api.depends("sale_type_id")
+def_compute_invoice_payment_term_id(self):
+# Records the user has explicitly touched are excluded from super().
+preserved=self.filtered(
+lambdam:m._user_set("invoice_payment_term_id"),
+)
+super(AccountMove,self-preserved)._compute_invoice_payment_term_id()
+formovein(self-preserved).filtered("sale_type_id.payment_term_id"):
+move.invoice_payment_term_id=move.sale_type_id.payment_term_id
+
☐ provenance_selection and provenance_char widget variants
+(current MVP only ships provenance_m2o).
+
☐ Pre-commit cascade banner: intercept the onchange RPC result in the
+OWL form controller and surface “Field X is about to change from A → B
+because Y changed” before commit. ProseMirror filterTransaction is
+the conceptual reference.
+
☐ Draft-time cascade log panel (chatter-adjacent) listing every field
+cascade with timestamp, prior value, new value, cause field.
+
☐ Undo toast (Linear-style) — let the cascade happen, then offer a
+6-second window to revert just the overwritten field.
+
☐ Demo addon web_field_provenance_sale that wires sale.order +
+account.move fields touched by sale_order_type to the widget
+and applies the _user_set guard in the seven mirror computes.
+
☐ Upstream RFC to Odoo S.A.: expose _user_set on the ORM base
+class so this module becomes plumbing-only.
Bugs are tracked on GitHub Issues.
+In case of trouble, please check there if your issue has already been reported.
+If you spotted it first, help us to smash it by providing a detailed and welcomed
+feedback.
+
Do not contact contributors directly about support or help with technical issues.
OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+
diff --git a/web_field_provenance/static/src/provenance_badge.esm.js b/web_field_provenance/static/src/provenance_badge.esm.js
new file mode 100644
index 000000000000..5ba231e4e5d3
--- /dev/null
+++ b/web_field_provenance/static/src/provenance_badge.esm.js
@@ -0,0 +1,63 @@
+/** @odoo-module **/
+
+/*
+ * Provenance Badge — SAP S/4HANA "green-arrow" pattern for Odoo.
+ *
+ * Reads `_provenance` (a JSON map of {field_name: 'u'|'s'}) from the active
+ * record and renders a tiny icon next to fields that opted in via
+ * `ir.model.fields.track_provenance`. Clicking the badge flips provenance
+ * to "user" (anchoring the manual override) and reveals the original
+ * computed value in a tooltip.
+ *
+ * Usage:
+ *
+ * MVP scope: many2one only. selection/char follow the same shape.
+ */
+import {registry} from "@web/core/registry";
+import {useState} from "@odoo/owl";
+import {_t} from "@web/core/l10n/translation";
+import {Many2OneField, many2OneField} from "@web/views/fields/many2one/many2one_field";
+
+const SOURCE_USER = "u";
+const SOURCE_SYSTEM = "s";
+
+export class ProvenanceMany2OneField extends Many2OneField {
+ static template = "web_field_provenance.ProvenanceMany2One";
+ static components = {...Many2OneField.components};
+
+ setup() {
+ super.setup();
+ this.badge = useState({visible: true});
+ }
+
+ get provenance() {
+ const map = this.props.record.data._provenance || {};
+ return map[this.props.name] || SOURCE_SYSTEM;
+ }
+
+ get badgeTitle() {
+ if (this.provenance === SOURCE_USER) {
+ return _t("Value set by user — will be preserved on recompute");
+ }
+ return _t(
+ "Value derived from a default or computed rule — may change if upstream fields change"
+ );
+ }
+
+ onBadgeClick(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const current = {...(this.props.record.data._provenance || {})};
+ current[this.props.name] = SOURCE_USER;
+ this.props.record.update({_provenance: current});
+ }
+}
+
+export const provenanceMany2OneField = {
+ ...many2OneField,
+ component: ProvenanceMany2OneField,
+ displayName: ({string}) => string,
+ supportedOptions: many2OneField.supportedOptions,
+};
+
+registry.category("fields").add("provenance_m2o", provenanceMany2OneField);
diff --git a/web_field_provenance/static/src/provenance_badge.scss b/web_field_provenance/static/src/provenance_badge.scss
new file mode 100644
index 000000000000..112d83685c40
--- /dev/null
+++ b/web_field_provenance/static/src/provenance_badge.scss
@@ -0,0 +1,25 @@
+.o_provenance_field_wrapper {
+ .o_provenance_badge {
+ background: transparent;
+ border: 0;
+ line-height: 1;
+ cursor: pointer;
+ opacity: 0.55;
+ transition: opacity 0.15s ease;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ i {
+ font-size: 0.85rem;
+ }
+
+ &.o_provenance_user i {
+ color: var(--bs-primary, #714b67);
+ }
+ &.o_provenance_system i {
+ color: var(--bs-success, #28a745);
+ }
+ }
+}
diff --git a/web_field_provenance/static/src/provenance_badge.xml b/web_field_provenance/static/src/provenance_badge.xml
new file mode 100644
index 000000000000..064bed54b1c3
--- /dev/null
+++ b/web_field_provenance/static/src/provenance_badge.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web_field_provenance/tests/__init__.py b/web_field_provenance/tests/__init__.py
new file mode 100644
index 000000000000..d9fe20d7ed6b
--- /dev/null
+++ b/web_field_provenance/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_provenance
diff --git a/web_field_provenance/tests/test_provenance.py b/web_field_provenance/tests/test_provenance.py
new file mode 100644
index 000000000000..ab55b4f46ca2
--- /dev/null
+++ b/web_field_provenance/tests/test_provenance.py
@@ -0,0 +1,62 @@
+# Copyright 2026 Ledoweb (Dan Kendall)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+from odoo.addons.base.tests.common import BaseCommon
+
+
+class TestProvenance(BaseCommon):
+ """Smoke tests for the provenance stamping + _user_set helper.
+
+ Uses res.partner as a probe model because it's universally available.
+ We opt-in `comment` to track_provenance and exercise the read/write
+ paths. The compute-cascade preserve pattern is covered in a separate
+ integration suite once the demo addon ships.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.field = cls.env["ir.model.fields"]._get("res.partner", "comment")
+ cls.field.write({"track_provenance": True})
+
+ def test_create_stamps_user(self):
+ partner = self.env["res.partner"].create(
+ {
+ "name": "Provenance Test",
+ "comment": "manual note",
+ }
+ )
+ self.assertEqual(
+ (partner._provenance or {}).get("comment"),
+ "u",
+ "Field set in create() should be stamped as user-supplied",
+ )
+ self.assertTrue(partner._user_set("comment"))
+
+ def test_write_stamps_user(self):
+ partner = self.env["res.partner"].create({"name": "No Comment"})
+ self.assertFalse((partner._provenance or {}).get("comment"))
+ partner.write({"comment": "user-typed"})
+ self.assertEqual(
+ (partner._provenance or {}).get("comment"),
+ "u",
+ "Subsequent user write() should overwrite provenance to 'u'",
+ )
+
+ def test_user_set_falls_back_to_origin(self):
+ partner = self.env["res.partner"].create({"name": "X", "comment": "A"})
+ new = partner.new(origin=partner)
+ new.comment = "B"
+ self.assertTrue(
+ new._user_set("comment"),
+ "Transient edit should be detected via _origin comparison even "
+ "without an explicit provenance stamp",
+ )
+
+ def test_untracked_field_not_stamped(self):
+ partner = self.env["res.partner"].create({"name": "Tracked Only"})
+ prov = partner._provenance or {}
+ self.assertNotIn(
+ "name",
+ prov,
+ "Fields without track_provenance=True must not bloat the JSON map",
+ )
diff --git a/web_field_provenance/views/ir_model_fields_views.xml b/web_field_provenance/views/ir_model_fields_views.xml
new file mode 100644
index 000000000000..cf378bf32462
--- /dev/null
+++ b/web_field_provenance/views/ir_model_fields_views.xml
@@ -0,0 +1,13 @@
+
+
+
+ ir.model.fields.form.inherit.web_field_provenance
+ ir.model.fields
+
+
+
+
+
+
+
+
From 63fa9c08f8d94d735a6c4ff86d28195e764cae3a Mon Sep 17 00:00:00 2001
From: Dan Kendall
Date: Sat, 16 May 2026 16:29:00 -0400
Subject: [PATCH 2/9] [FIX] web_field_provenance: correctness pass before first
install
---
web_field_provenance/models/base_model.py | 133 +++++++++---------
.../models/ir_model_fields.py | 30 +++-
web_field_provenance/tests/test_provenance.py | 52 +++++--
3 files changed, 131 insertions(+), 84 deletions(-)
diff --git a/web_field_provenance/models/base_model.py b/web_field_provenance/models/base_model.py
index 8396f925d386..8ce50d0a274a 100644
--- a/web_field_provenance/models/base_model.py
+++ b/web_field_provenance/models/base_model.py
@@ -7,20 +7,20 @@
1. Per-field "did the user set this?" probe — `_user_set(fname)` — based on
`record._origin`. Cheap, no schema cost, available to every model.
-2. Per-field provenance map — a transient JSON column `_provenance` that
- records which fields were last written by a user vs. assigned by a
- compute. Only populated for fields whose `ir.model.fields.track_provenance`
+2. Per-field provenance map — a JSON column `_provenance` that records
+ which fields were last written by a user vs. assigned by a compute.
+ Only populated for fields whose `ir.model.fields.track_provenance`
flag is set. The web client reads this map and renders a small badge
next to opted-in fields (system gear vs. user pencil), Salesforce/SAP
style.
The two paths cooperate: in compute methods, callers can guard with
-`if self._user_set('payment_term_id'): return` to preserve manual values
-during recompute cascades. The provenance map then lights up the badge so
-the user sees the system gave way to their choice.
+`if record._user_set('payment_term_id'): continue` to preserve manual
+values during recompute cascades. The provenance map then lights up the
+badge so the user sees the system gave way to their choice.
"""
-from odoo import api, fields, models
+from odoo import api, fields, models, tools
# Marker key for provenance values. Kept short to keep the JSON column
# compact when many fields opt in.
@@ -49,61 +49,91 @@ def _user_set(self, fname):
trust it (explicit, persisted).
2. Else fall back to `_origin` comparison: if the current value
differs from the DB-saved value AND is non-falsy, treat as
- user-touched (transactional dirty bit).
- 3. Otherwise False.
-
- The compute-cascade-preserve pattern looks like:
-
- @api.depends("sale_type_id")
- def _compute_invoice_payment_term_id(self):
- preserved = self.filtered(
- lambda m: m._user_set("invoice_payment_term_id"),
- )
- super(AccountMove, self - preserved)._compute_invoice_payment_term_id()
+ user-touched (transactional dirty bit). NewId records expose
+ ``_origin`` pointing at the persisted record; regular records
+ return ``self`` for ``_origin`` so the comparison naturally
+ yields False outside an active transaction.
"""
self.ensure_one()
prov = self._provenance or {}
if prov.get(fname) == _USER:
return True
origin = self._origin
- if origin and origin[fname] and self[fname] != origin[fname]:
+ if (
+ origin
+ and origin is not self
+ and self[fname]
+ and origin[fname] != self[fname]
+ ):
return True
return False
# ------------------------------------------------------------------
- # ORM hooks — stamp provenance on writes
+ # ORM hooks — stamp provenance on user writes
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
- records._stamp_provenance_from_vals(vals_list, source=_USER)
+ if records._field_track_set():
+ records._stamp_provenance_from_vals(vals_list, source=_USER)
return records
def write(self, vals):
- # Snapshot incoming user-driven field names BEFORE write so we
- # can distinguish them from cascade-recompute writes performed
- # inside super().write() via the ORM's compute pass.
- user_keys = [k for k in vals if self._field_tracks_provenance(k)]
+ # Skip the stamping path entirely when called from our own
+ # provenance bookkeeping or when no tracked field is in vals.
+ if self.env.context.get("_prov_skip"):
+ return super().write(vals)
+ tracked = self._field_track_set()
+ user_keys = [k for k in vals if k in tracked]
res = super().write(vals)
if user_keys:
self._stamp_provenance_keys(user_keys, source=_USER)
return res
+ # ------------------------------------------------------------------
+ # Modern web client entry points — surface _provenance alongside
+ # tracked fields. Form views in 18.0 call web_read / web_search_read;
+ # the legacy read() override is kept for backwards compatibility but
+ # is rarely the hot path.
+ # ------------------------------------------------------------------
+ def web_read(self, specification):
+ result = super().web_read(specification)
+ if not result or not self._field_track_set():
+ return result
+ # Only emit the map when the client asked for a tracked field.
+ if not any(name in self._field_track_set() for name in specification):
+ return result
+ prov_by_id = {r.id: (r._provenance or {}) for r in self}
+ for row in result:
+ row["_provenance"] = prov_by_id.get(row.get("id"), {})
+ return result
+
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
- def _field_tracks_provenance(self, fname):
- """Cheap cache: is this field opted in via ir.model.fields.track_provenance?"""
- if fname.startswith("_") or fname not in self._fields:
- return False
- # Lookup is bound to the registry — avoid round-trip on every write.
- return bool(
- self.env["ir.model.fields"]._get(self._name, fname).track_provenance
+ @api.model
+ @tools.ormcache("self._name")
+ def _field_track_set(self):
+ """Cached set of field names on this model that opt-in via
+ `ir.model.fields.track_provenance=True`. Invalidated when an
+ `ir.model.fields` row toggles the flag (see ir_model_fields.py).
+ """
+ return frozenset(
+ self.env["ir.model.fields"]
+ .sudo()
+ .search(
+ [
+ ("model", "=", self._name),
+ ("track_provenance", "=", True),
+ ]
+ )
+ .mapped("name")
)
def _stamp_provenance_from_vals(self, vals_list, source):
+ tracked = self._field_track_set()
for rec, vals in zip(self, vals_list, strict=False):
- keys = [k for k in (vals or {}) if rec._field_tracks_provenance(k)]
+ keys = [k for k in (vals or {}) if k in tracked]
if keys:
rec._stamp_provenance_keys(keys, source=source)
@@ -112,39 +142,4 @@ def _stamp_provenance_keys(self, keys, source):
current = dict(rec._provenance or {})
for k in keys:
current[k] = source
- # Bypass the public write() to avoid infinite recursion.
- rec.sudo().with_context(prevent_provenance_stamp=True)._write(
- {
- "_provenance": current,
- }
- )
-
- # ------------------------------------------------------------------
- # Expose provenance to the web client via read()
- # ------------------------------------------------------------------
- def read(self, fields=None, load="_classic_read"):
- """Inject `_provenance` into reads if the client asked for any
- tracked field. The OWL widget consumes this map to render a badge.
- """
- result = super().read(fields=fields, load=load)
- if not result:
- return result
- wants_provenance = fields is None or any(
- f != "_provenance" and self._field_tracks_provenance(f) for f in fields
- )
- if not wants_provenance:
- return result
- # Re-fetch _provenance for every read row we didn't already include.
- if fields is not None and "_provenance" not in fields:
- ids = [r["id"] for r in result]
- extra = {
- r["id"]: r["_provenance"]
- for r in super().read(
- fields=["_provenance"],
- load=load,
- )
- if r["id"] in ids
- }
- for row in result:
- row["_provenance"] = extra.get(row["id"]) or {}
- return result
+ rec.with_context(_prov_skip=True).write({"_provenance": current})
diff --git a/web_field_provenance/models/ir_model_fields.py b/web_field_provenance/models/ir_model_fields.py
index fce60ed576c1..c4c67c410dda 100644
--- a/web_field_provenance/models/ir_model_fields.py
+++ b/web_field_provenance/models/ir_model_fields.py
@@ -1,6 +1,6 @@
# Copyright 2026 Ledoweb (Dan Kendall)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
-from odoo import fields, models
+from odoo import api, fields, models
class IrModelFields(models.Model):
@@ -12,3 +12,31 @@ class IrModelFields(models.Model):
"Only meaningful on computed-writable fields "
"(compute=..., store=True, readonly=False).",
)
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ records = super().create(vals_list)
+ if any(v.get("track_provenance") for v in vals_list):
+ self._invalidate_track_cache(records.mapped("model"))
+ return records
+
+ def write(self, vals):
+ res = super().write(vals)
+ if "track_provenance" in vals:
+ self._invalidate_track_cache(self.mapped("model"))
+ return res
+
+ def unlink(self):
+ models_affected = self.filtered("track_provenance").mapped("model")
+ res = super().unlink()
+ if models_affected:
+ self._invalidate_track_cache(models_affected)
+ return res
+
+ def _invalidate_track_cache(self, model_names):
+ """Drop the cached `_field_track_set`. Toggling `track_provenance` is
+ a rare admin action so a full ormcache flush is acceptable; finer-
+ grained invalidation isn't worth the API-stability risk against
+ Odoo's private cache internals."""
+ if model_names:
+ self.env.registry.clear_cache()
diff --git a/web_field_provenance/tests/test_provenance.py b/web_field_provenance/tests/test_provenance.py
index ab55b4f46ca2..3bd14426b6d3 100644
--- a/web_field_provenance/tests/test_provenance.py
+++ b/web_field_provenance/tests/test_provenance.py
@@ -7,16 +7,13 @@ class TestProvenance(BaseCommon):
"""Smoke tests for the provenance stamping + _user_set helper.
Uses res.partner as a probe model because it's universally available.
- We opt-in `comment` to track_provenance and exercise the read/write
- paths. The compute-cascade preserve pattern is covered in a separate
- integration suite once the demo addon ships.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
- cls.field = cls.env["ir.model.fields"]._get("res.partner", "comment")
- cls.field.write({"track_provenance": True})
+ cls.field_comment = cls.env["ir.model.fields"]._get("res.partner", "comment")
+ cls.field_comment.write({"track_provenance": True})
def test_create_stamps_user(self):
partner = self.env["res.partner"].create(
@@ -42,15 +39,12 @@ def test_write_stamps_user(self):
"Subsequent user write() should overwrite provenance to 'u'",
)
- def test_user_set_falls_back_to_origin(self):
- partner = self.env["res.partner"].create({"name": "X", "comment": "A"})
- new = partner.new(origin=partner)
- new.comment = "B"
- self.assertTrue(
- new._user_set("comment"),
- "Transient edit should be detected via _origin comparison even "
- "without an explicit provenance stamp",
- )
+ def test_user_set_via_explicit_stamp(self):
+ """When the provenance map records 'u', _user_set must return True
+ regardless of the _origin comparison."""
+ partner = self.env["res.partner"].create({"name": "Anchored"})
+ partner._stamp_provenance_keys(["comment"], source="u")
+ self.assertTrue(partner._user_set("comment"))
def test_untracked_field_not_stamped(self):
partner = self.env["res.partner"].create({"name": "Tracked Only"})
@@ -60,3 +54,33 @@ def test_untracked_field_not_stamped(self):
prov,
"Fields without track_provenance=True must not bloat the JSON map",
)
+
+ def test_cache_invalidated_on_flag_toggle(self):
+ # The "comment" field is tracked from setUpClass; remove the flag
+ # and confirm new writes no longer stamp.
+ self.field_comment.write({"track_provenance": False})
+ partner = self.env["res.partner"].create(
+ {
+ "name": "Post-Toggle",
+ "comment": "no longer tracked",
+ }
+ )
+ self.assertNotIn(
+ "comment",
+ (partner._provenance or {}),
+ "After toggling track_provenance off, comment should not be stamped",
+ )
+ # Restore for any subsequent tests
+ self.field_comment.write({"track_provenance": True})
+
+ def test_web_read_emits_provenance(self):
+ partner = self.env["res.partner"].create(
+ {
+ "name": "WebRead Probe",
+ "comment": "anchored",
+ }
+ )
+ rows = partner.web_read({"comment": {}, "name": {}})
+ self.assertEqual(len(rows), 1)
+ self.assertIn("_provenance", rows[0])
+ self.assertEqual(rows[0]["_provenance"].get("comment"), "u")
From 5509b492b0b074cfa86aa3538b23257f36b8a276 Mon Sep 17 00:00:00 2001
From: Don Kendall
Date: Sat, 16 May 2026 18:21:29 -0400
Subject: [PATCH 3/9] [IMP] web_field_provenance: 3-state schema
(default/rule/user) + public stamping API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Schema change in `_provenance`:
- Absence of an entry → field still at its default (no stamp needed).
- {"s":"u", "b":, "t":} → user-set.
- {"s":"r", "b":, "t":, "r":} → rule / cascade / EDI.
Defaults are intentionally not stamped to keep the JSON column compact;
`_provenance_for` uses `record.create_date` as the "default since" proxy
without storing extra data.
Adds:
- Public `_stamp_provenance(keys, *, source, by, rule=None, when=None)`
for cascades, integrations, and EDI writers. Rejects unknown sources
and empty `by` to keep tooltips informative.
- `_provenance_for(fname)` returning the tooltip dict the OWL widget
renders. Three states: default, user, rule.
- OWL widget renders three icons (grey dot / green cog / pencil) and
a hover tooltip with writer + timestamp.
Tests: 17 cases covering the user/rule/default paths, the public API
contract (rejected sources, empty `by`, untracked-key filter), the
override flow (rule -> user write flips state), legacy bare-string
entries (backward-compat read), and explicit-timestamp injection.
Test fixture uses SQL to flip `track_provenance` on a base field
because Odoo blocks `ir.model.fields.write()` on `state='base'`. The
production opt-in wizard is a documented ROADMAP item.
ROADMAP extended with the API / EDI integration sub-track and the
opt-in wizard.
---
web_field_provenance/README.rst | 26 ++
web_field_provenance/models/base_model.py | 232 ++++++++++++++----
web_field_provenance/readme/ROADMAP.md | 24 ++
.../static/description/index.html | 27 ++
.../static/src/provenance_badge.esm.js | 83 +++++--
.../static/src/provenance_badge.xml | 6 +-
web_field_provenance/tests/test_provenance.py | 217 ++++++++++++----
7 files changed, 502 insertions(+), 113 deletions(-)
diff --git a/web_field_provenance/README.rst b/web_field_provenance/README.rst
index a53750a70109..cc6e054b7e10 100644
--- a/web_field_provenance/README.rst
+++ b/web_field_provenance/README.rst
@@ -107,6 +107,12 @@ Clicking the icon promotes the current value to user-anchored.
Known issues / Roadmap
======================
+- ☐ **Opt-in wizard for base fields.** Odoo blocks
+ ``ir.model.fields.write()`` on ``state='base'`` fields, so
+ ``track_provenance`` can't be toggled from the generic technical view
+ today. Ship a small wizard (Settings → Technical → Field Provenance
+ Setup) that picks (model, field) tuples and applies the flag via a SQL
+ UPDATE + ``registry.clear_cache()``. Tests already use this bypass.
- ☐ **provenance_selection** and **provenance_char** widget variants
(current MVP only ships ``provenance_m2o``).
- ☐ Pre-commit cascade banner: intercept the onchange RPC result in the
@@ -122,6 +128,26 @@ Known issues / Roadmap
and applies the ``_user_set`` guard in the seven mirror computes.
- ☐ Upstream RFC to Odoo S.A.: expose ``_user_set`` on the ORM base
class so this module becomes plumbing-only.
+- ☐ **API / EDI integration** (sub-track — separate from the MVP):
+
+ - Documented
+ ``_stamp_provenance(..., source='r', by='edi:')`` hook
+ for inbound EDI / XML-RPC writers. Connector modules call this when
+ applying values on behalf of an upstream system so the badge
+ attributes correctly.
+ - Conflict policy on re-import: when an inbound EDI rewrites a ``s=u``
+ field, decide between "preserve user (block)", "override and log
+ prior in chatter", or "override and re-stamp as ``r=edi:...``".
+ Per-integration setting.
+ - Provenance serializer ``_provenance_to_audit_dict`` for outbound
+ audit exports (UBL/Peppol BTG-65-equivalent "Originator Document
+ Reference" semantics).
+ - Bulk-import (``load()``, base_import) stamping with ``b='import'``
+ and optional batch identifier so audit can trace a row to a specific
+ import job.
+ - Benchmark JSON-column overhead at 100+ tracked fields per record;
+ consider migrating to a ``mail.tracking.value``-style external table
+ if the inline column starts dominating query costs.
Bug Tracker
===========
diff --git a/web_field_provenance/models/base_model.py b/web_field_provenance/models/base_model.py
index 8ce50d0a274a..941e17e03b3c 100644
--- a/web_field_provenance/models/base_model.py
+++ b/web_field_provenance/models/base_model.py
@@ -2,30 +2,49 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
"""Provenance machinery for computed-writable fields.
-Two ideas implemented here:
-
-1. Per-field "did the user set this?" probe — `_user_set(fname)` — based on
- `record._origin`. Cheap, no schema cost, available to every model.
-
-2. Per-field provenance map — a JSON column `_provenance` that records
- which fields were last written by a user vs. assigned by a compute.
- Only populated for fields whose `ir.model.fields.track_provenance`
- flag is set. The web client reads this map and renders a small badge
- next to opted-in fields (system gear vs. user pencil), Salesforce/SAP
- style.
-
-The two paths cooperate: in compute methods, callers can guard with
-`if record._user_set('payment_term_id'): continue` to preserve manual
-values during recompute cascades. The provenance map then lights up the
-badge so the user sees the system gave way to their choice.
+Two cooperating ideas:
+
+1. Per-field "did the user set this?" probe — `_user_set(fname)` — combining
+ the persisted provenance map with an `_origin` transactional fallback.
+ Available on every model.
+
+2. Per-field provenance map — a JSON column `_provenance` recording, for
+ every opted-in field, *how* it got its current value:
+
+ - **No entry** → field still at its default. Implicit. We never
+ stamp the default state to keep the JSON small;
+ the badge falls back to `record.create_date`.
+ - `{"s": "u", ...}` → set by a user (login captured in `b`).
+ - `{"s": "r", ...}` → set by a rule / cascade / integration
+ (writer identifier in `b`, human-readable label
+ in `r`).
+
+ Each entry also carries a unix timestamp `t`, surfaced to the OWL badge
+ so the hover tooltip can say "Set by *dkendall* 12 minutes ago" or
+ "Set by *Sale Order Type cascade* at 14:02".
+
+Only fields opted-in via `ir.model.fields.track_provenance=True` are
+stamped — keeps cost at zero for the typical record.
+
+The OWL widget consumes `_provenance` from `web_read` and renders a small
+icon next to the field; the `_provenance_for(fname)` method returns the
+tooltip dict.
"""
+import logging
+import time
+from datetime import datetime, timezone
+
from odoo import api, fields, models, tools
-# Marker key for provenance values. Kept short to keep the JSON column
-# compact when many fields opt in.
+_logger = logging.getLogger(__name__)
+
+# Source short-codes, kept terse so the JSON column stays compact when
+# many fields opt in. Matches the convention in mail.tracking.value and
+# sale.order.line.extra_tax_data.
_USER = "u"
-_SYSTEM = "s"
+_RULE = "r"
+_VALID_SOURCES = (_USER, _RULE)
class Base(models.AbstractModel):
@@ -33,30 +52,32 @@ class Base(models.AbstractModel):
_provenance = fields.Json(
string="Field Provenance",
- help="Per-field provenance map: {field_name: 'u'|'s'}. "
- "'u' = user-supplied, 's' = system-assigned (compute/default).",
+ help="Per-field provenance map.\n"
+ "Absence of an entry means the field is still at its default. "
+ "Entries are of the form {s, b, t, r?}: "
+ "s=source ('u'=user, 'r'=rule/cascade), "
+ "b=writer identifier (login or rule id), "
+ "t=unix timestamp, "
+ "r=optional human-readable rule label.",
copy=False,
)
# ------------------------------------------------------------------
- # Helper API consumed by computes / wizards / tests
+ # Public helper API
# ------------------------------------------------------------------
def _user_set(self, fname):
- """Return True if `fname` was set by the user, not by a compute.
+ """Return True if `fname` was set by the user, not by a default
+ or a cascade.
Resolution order:
- 1. If a provenance map exists and records 'u' for this field,
- trust it (explicit, persisted).
- 2. Else fall back to `_origin` comparison: if the current value
- differs from the DB-saved value AND is non-falsy, treat as
- user-touched (transactional dirty bit). NewId records expose
- ``_origin`` pointing at the persisted record; regular records
- return ``self`` for ``_origin`` so the comparison naturally
- yields False outside an active transaction.
+ 1. Persisted provenance map: entry with `s == 'u'` ⇒ True.
+ 2. `_origin` fallback: a NewId record whose current value
+ differs from the persisted one and is non-falsy is treated
+ as user-touched (in-form dirty bit before save).
"""
self.ensure_one()
- prov = self._provenance or {}
- if prov.get(fname) == _USER:
+ entry = self._provenance_entry(fname)
+ if entry.get("s") == _USER:
return True
origin = self._origin
if (
@@ -68,6 +89,105 @@ def _user_set(self, fname):
return True
return False
+ def _stamp_provenance(self, keys, *, source, by, rule=None, when=None):
+ """Public API for cascade methods and integrations.
+
+ Use this when a rule, cascade, EDI inbound, import wizard, or any
+ other non-user writer applies a value, so that the badge surface
+ can attribute the value correctly.
+
+ :param keys: iterable of tracked field names to stamp.
+ :param source: 'u' (user) or 'r' (rule/cascade). Do not stamp the
+ default state — its absence is informative.
+ :param by: stable string identifier of the writer. Required and
+ non-empty. Examples:
+ - 'sot.cascade' for sale_order_type propagation
+ - 'edi:l10n_gr_edi' for Greek e-invoicing inbound
+ - 'import' for bulk loaders
+ - a user login for explicit attribution.
+ :param rule: optional human-readable label rendered in the badge
+ tooltip ("Sale Order Type cascade").
+ :param when: optional unix timestamp; defaults to `time.time()`.
+ """
+ if source not in _VALID_SOURCES:
+ raise ValueError(
+ "_stamp_provenance: source must be 'u' or 'r' "
+ f"(absence of an entry already means default); got {source!r}"
+ )
+ if not by:
+ raise ValueError(
+ "_stamp_provenance: 'by' must be a non-empty string identifier"
+ )
+ tracked = self._field_track_set()
+ keys = [k for k in keys if k in tracked]
+ if not keys:
+ return
+ self._stamp_provenance_keys(
+ keys,
+ source=source,
+ by=by,
+ rule=rule,
+ when=when,
+ )
+
+ def _provenance_for(self, fname):
+ """Return the badge-tooltip dict for `fname`.
+
+ Shape:
+ {
+ "state": "default" | "user" | "rule",
+ "label": human-readable summary,
+ "by": writer identifier (omitted for default),
+ "rule": rule label (only for state=="rule" with `r`),
+ "when": ISO-8601 timestamp (None if unknown),
+ }
+
+ Consumed by the OWL widget; also useful in tests for golden
+ assertions.
+ """
+ self.ensure_one()
+ entry = self._provenance_entry(fname)
+ if not entry or "s" not in entry:
+ return {
+ "state": "default",
+ "label": "Default value",
+ "when": (
+ self.create_date.replace(tzinfo=timezone.utc).isoformat()
+ if self.create_date
+ else None
+ ),
+ }
+ source = entry.get("s")
+ by = entry.get("b") or "system"
+ when = entry.get("t")
+ when_iso = (
+ datetime.fromtimestamp(when, tz=timezone.utc).isoformat()
+ if isinstance(when, int | float)
+ else None
+ )
+ if source == _USER:
+ return {
+ "state": "user",
+ "by": by,
+ "label": f"Set by user {by}",
+ "when": when_iso,
+ }
+ if source == _RULE:
+ label_name = entry.get("r") or by
+ return {
+ "state": "rule",
+ "by": by,
+ "rule": entry.get("r"),
+ "label": f"Set by {label_name}",
+ "when": when_iso,
+ }
+ # Unknown source — degrade to default semantics rather than crash.
+ return {
+ "state": "default",
+ "label": "Default value",
+ "when": None,
+ }
+
# ------------------------------------------------------------------
# ORM hooks — stamp provenance on user writes
# ------------------------------------------------------------------
@@ -79,8 +199,6 @@ def create(self, vals_list):
return records
def write(self, vals):
- # Skip the stamping path entirely when called from our own
- # provenance bookkeeping or when no tracked field is in vals.
if self.env.context.get("_prov_skip"):
return super().write(vals)
tracked = self._field_track_set()
@@ -91,16 +209,14 @@ def write(self, vals):
return res
# ------------------------------------------------------------------
- # Modern web client entry points — surface _provenance alongside
- # tracked fields. Form views in 18.0 call web_read / web_search_read;
- # the legacy read() override is kept for backwards compatibility but
- # is rarely the hot path.
+ # Web client integration — surface _provenance alongside tracked
+ # fields. Form views in 18.0+ call web_read; the legacy read()
+ # override is kept thin for backwards compat.
# ------------------------------------------------------------------
def web_read(self, specification):
result = super().web_read(specification)
if not result or not self._field_track_set():
return result
- # Only emit the map when the client asked for a tracked field.
if not any(name in self._field_track_set() for name in specification):
return result
prov_by_id = {r.id: (r._provenance or {}) for r in self}
@@ -114,9 +230,9 @@ def web_read(self, specification):
@api.model
@tools.ormcache("self._name")
def _field_track_set(self):
- """Cached set of field names on this model that opt-in via
- `ir.model.fields.track_provenance=True`. Invalidated when an
- `ir.model.fields` row toggles the flag (see ir_model_fields.py).
+ """Cached set of opted-in field names on this model. Invalidated
+ when `ir.model.fields.track_provenance` toggles (see
+ ir_model_fields.py).
"""
return frozenset(
self.env["ir.model.fields"]
@@ -130,6 +246,18 @@ def _field_track_set(self):
.mapped("name")
)
+ def _provenance_entry(self, fname):
+ """Return a dict-shaped entry for `fname`, normalizing the legacy
+ bare-string form (`"u"` / `"s"`) into the dict shape. Callers
+ outside this module should prefer `_provenance_for`.
+ """
+ self.ensure_one()
+ raw = (self._provenance or {}).get(fname)
+ if isinstance(raw, str):
+ # Legacy v0.1 schema — keep readable while DBs migrate.
+ return {"s": raw} if raw in _VALID_SOURCES else {}
+ return dict(raw) if raw else {}
+
def _stamp_provenance_from_vals(self, vals_list, source):
tracked = self._field_track_set()
for rec, vals in zip(self, vals_list, strict=False):
@@ -137,9 +265,23 @@ def _stamp_provenance_from_vals(self, vals_list, source):
if keys:
rec._stamp_provenance_keys(keys, source=source)
- def _stamp_provenance_keys(self, keys, source):
+ def _stamp_provenance_keys(self, keys, source, by=None, when=None, rule=None):
+ """Write the stamping. One UPDATE per record, regardless of
+ how many keys are stamped.
+
+ `by` defaults to `env.user.login` for user writes. For rule writes
+ the public `_stamp_provenance` already enforced a non-empty `by`,
+ so this private path shouldn't see `None`.
+ """
+ if not by:
+ by = self.env.user.login if source == _USER else "system"
+ if when is None:
+ when = int(time.time())
for rec in self:
current = dict(rec._provenance or {})
for k in keys:
- current[k] = source
+ entry = {"s": source, "b": by, "t": when}
+ if rule:
+ entry["r"] = rule
+ current[k] = entry
rec.with_context(_prov_skip=True).write({"_provenance": current})
diff --git a/web_field_provenance/readme/ROADMAP.md b/web_field_provenance/readme/ROADMAP.md
index 22aae6aa7767..4705b2a9402c 100644
--- a/web_field_provenance/readme/ROADMAP.md
+++ b/web_field_provenance/readme/ROADMAP.md
@@ -1,3 +1,9 @@
+- [ ] **Opt-in wizard for base fields.** Odoo blocks `ir.model.fields.write()`
+ on `state='base'` fields, so `track_provenance` can't be toggled from the
+ generic technical view today. Ship a small wizard
+ (Settings → Technical → Field Provenance Setup) that picks
+ (model, field) tuples and applies the flag via a SQL UPDATE +
+ `registry.clear_cache()`. Tests already use this bypass.
- [ ] **provenance_selection** and **provenance_char** widget variants
(current MVP only ships `provenance_m2o`).
- [ ] Pre-commit cascade banner: intercept the onchange RPC result in
@@ -13,3 +19,21 @@
applies the `_user_set` guard in the seven mirror computes.
- [ ] Upstream RFC to Odoo S.A.: expose `_user_set` on the ORM base
class so this module becomes plumbing-only.
+- [ ] **API / EDI integration** (sub-track — separate from the MVP):
+ - Documented `_stamp_provenance(..., source='r', by='edi:')`
+ hook for inbound EDI / XML-RPC writers. Connector modules call this
+ when applying values on behalf of an upstream system so the badge
+ attributes correctly.
+ - Conflict policy on re-import: when an inbound EDI rewrites a
+ `s=u` field, decide between "preserve user (block)", "override and
+ log prior in chatter", or "override and re-stamp as `r=edi:...`".
+ Per-integration setting.
+ - Provenance serializer `_provenance_to_audit_dict` for outbound
+ audit exports (UBL/Peppol BTG-65-equivalent "Originator Document
+ Reference" semantics).
+ - Bulk-import (`load()`, base_import) stamping with `b='import'` and
+ optional batch identifier so audit can trace a row to a specific
+ import job.
+ - Benchmark JSON-column overhead at 100+ tracked fields per record;
+ consider migrating to a `mail.tracking.value`-style external table
+ if the inline column starts dominating query costs.
diff --git a/web_field_provenance/static/description/index.html b/web_field_provenance/static/description/index.html
index 1a429443de29..1dc0261edaa5 100644
--- a/web_field_provenance/static/description/index.html
+++ b/web_field_provenance/static/description/index.html
@@ -454,6 +454,12 @@
☐ Opt-in wizard for base fields. Odoo blocks
+ir.model.fields.write() on state='base' fields, so
+track_provenance can’t be toggled from the generic technical view
+today. Ship a small wizard (Settings → Technical → Field Provenance
+Setup) that picks (model, field) tuples and applies the flag via a SQL
+UPDATE + registry.clear_cache(). Tests already use this bypass.
☐ provenance_selection and provenance_char widget variants
(current MVP only ships provenance_m2o).
☐ Pre-commit cascade banner: intercept the onchange RPC result in the
@@ -469,6 +475,27 @@
and applies the _user_set guard in the seven mirror computes.
☐ Upstream RFC to Odoo S.A.: expose _user_set on the ORM base
class so this module becomes plumbing-only.
+
☐ API / EDI integration (sub-track — separate from the MVP):
+
Documented
+_stamp_provenance(...,source='r',by='edi:<connector>') hook
+for inbound EDI / XML-RPC writers. Connector modules call this when
+applying values on behalf of an upstream system so the badge
+attributes correctly.
+
Conflict policy on re-import: when an inbound EDI rewrites a s=u
+field, decide between “preserve user (block)”, “override and log
+prior in chatter”, or “override and re-stamp as r=edi:...”.
+Per-integration setting.
Bulk-import (load(), base_import) stamping with b='import'
+and optional batch identifier so audit can trace a row to a specific
+import job.
+
Benchmark JSON-column overhead at 100+ tracked fields per record;
+consider migrating to a mail.tracking.value-style external table
+if the inline column starts dominating query costs.
+
+
diff --git a/web_field_provenance/static/src/provenance_badge.esm.js b/web_field_provenance/static/src/provenance_badge.esm.js
index 5ba231e4e5d3..2b9e5c50f8c9 100644
--- a/web_field_provenance/static/src/provenance_badge.esm.js
+++ b/web_field_provenance/static/src/provenance_badge.esm.js
@@ -1,17 +1,25 @@
/** @odoo-module **/
/*
- * Provenance Badge — SAP S/4HANA "green-arrow" pattern for Odoo.
+ * Provenance Badge — Salesforce/SAP-style "where did this value come
+ * from?" indicator for Odoo.
*
- * Reads `_provenance` (a JSON map of {field_name: 'u'|'s'}) from the active
- * record and renders a tiny icon next to fields that opted in via
- * `ir.model.fields.track_provenance`. Clicking the badge flips provenance
- * to "user" (anchoring the manual override) and reveals the original
- * computed value in a tooltip.
+ * Reads `_provenance` (a JSON map of {field: {s, b, t, r?}}) from the
+ * active record and renders a small icon next to fields that opted in
+ * via `ir.model.fields.track_provenance`. Three visual states:
+ *
+ * - default: grey outline circle — field is still at its default
+ * (no entry in the map).
+ * - rule: green cog — set by a rule / cascade / EDI integration.
+ * - user: pencil — set by a user write.
+ *
+ * Hover reveals the writer and timestamp ("Set by user dkendall at
+ * 2026-05-16T20:15Z", "Set by Sale Order Type cascade", etc.).
+ * Clicking the badge promotes the value to user-anchored.
*
* Usage:
*
- * MVP scope: many2one only. selection/char follow the same shape.
+ * MVP scope: many2one only. Selection / char follow the same shape.
*/
import {registry} from "@web/core/registry";
import {useState} from "@odoo/owl";
@@ -19,7 +27,7 @@ import {_t} from "@web/core/l10n/translation";
import {Many2OneField, many2OneField} from "@web/views/fields/many2one/many2one_field";
const SOURCE_USER = "u";
-const SOURCE_SYSTEM = "s";
+const SOURCE_RULE = "r";
export class ProvenanceMany2OneField extends Many2OneField {
static template = "web_field_provenance.ProvenanceMany2One";
@@ -30,25 +38,68 @@ export class ProvenanceMany2OneField extends Many2OneField {
this.badge = useState({visible: true});
}
- get provenance() {
+ get entry() {
const map = this.props.record.data._provenance || {};
- return map[this.props.name] || SOURCE_SYSTEM;
+ const raw = map[this.props.name];
+ if (!raw) {
+ return null;
+ }
+ // Legacy bare-string schema: normalize to dict shape.
+ if (typeof raw === "string") {
+ return {s: raw};
+ }
+ return raw;
+ }
+
+ get state() {
+ const e = this.entry;
+ if (!e || !e.s) {
+ return "default";
+ }
+ if (e.s === SOURCE_USER) {
+ return "user";
+ }
+ if (e.s === SOURCE_RULE) {
+ return "rule";
+ }
+ return "default";
+ }
+
+ get badgeIconClass() {
+ if (this.state === "user") {
+ return "fa fa-pencil";
+ }
+ if (this.state === "rule") {
+ return "fa fa-cog text-success";
+ }
+ return "fa fa-circle-o text-muted";
}
get badgeTitle() {
- if (this.provenance === SOURCE_USER) {
- return _t("Value set by user — will be preserved on recompute");
+ const e = this.entry;
+ const when = e?.t ? new Date(e.t * 1000).toLocaleString() : null;
+ if (this.state === "user") {
+ return _t("Set by user %s%s", e.b || "?", when ? " at " + when : "");
+ }
+ if (this.state === "rule") {
+ const label = e.r || e.b || _t("a rule");
+ return _t("Set by %s%s", label, when ? " at " + when : "");
}
- return _t(
- "Value derived from a default or computed rule — may change if upstream fields change"
- );
+ return _t("Default value — set when the record was created");
}
onBadgeClick(ev) {
ev.stopPropagation();
ev.preventDefault();
const current = {...(this.props.record.data._provenance || {})};
- current[this.props.name] = SOURCE_USER;
+ // Promote to user-anchored. The `t` and `b` get filled in by the
+ // server on save (ORM write() handles user stamping); we set a
+ // sentinel here so the badge updates optimistically.
+ current[this.props.name] = {
+ s: SOURCE_USER,
+ b: this.env.services.user?.login || "user",
+ t: Math.floor(Date.now() / 1000),
+ };
this.props.record.update({_provenance: current});
}
}
diff --git a/web_field_provenance/static/src/provenance_badge.xml b/web_field_provenance/static/src/provenance_badge.xml
index 064bed54b1c3..52dfb6b0ab9b 100644
--- a/web_field_provenance/static/src/provenance_badge.xml
+++ b/web_field_provenance/static/src/provenance_badge.xml
@@ -7,14 +7,12 @@
diff --git a/web_field_provenance/tests/test_provenance.py b/web_field_provenance/tests/test_provenance.py
index 3bd14426b6d3..a6f966c3cb16 100644
--- a/web_field_provenance/tests/test_provenance.py
+++ b/web_field_provenance/tests/test_provenance.py
@@ -1,86 +1,207 @@
# Copyright 2026 Ledoweb (Dan Kendall)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
+import time
+
from odoo.addons.base.tests.common import BaseCommon
class TestProvenance(BaseCommon):
- """Smoke tests for the provenance stamping + _user_set helper.
+ """Provenance stamping + `_user_set` + `_provenance_for` coverage.
- Uses res.partner as a probe model because it's universally available.
+ Uses `res.partner.comment` as the probe field because it's
+ universally available and not driven by any compute, so we control
+ its provenance exclusively from these tests.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.field_comment = cls.env["ir.model.fields"]._get("res.partner", "comment")
- cls.field_comment.write({"track_provenance": True})
+ # Base fields (state='base') are protected from `.write()`; flip the
+ # opt-in flag via SQL. Production UX exposes the same operation
+ # via a custom wizard (Technical → Field Provenance Setup) — that
+ # wizard will use the same SQL path. The `clear_cache` ensures the
+ # ormcache on `_field_track_set` picks it up.
+ cls._set_track_provenance(cls.field_comment, True)
- def test_create_stamps_user(self):
- partner = self.env["res.partner"].create(
- {
- "name": "Provenance Test",
- "comment": "manual note",
- }
+ @classmethod
+ def _set_track_provenance(cls, field, enabled):
+ cls.env.cr.execute(
+ "UPDATE ir_model_fields SET track_provenance = %s WHERE id = %s",
+ [enabled, field.id],
)
- self.assertEqual(
- (partner._provenance or {}).get("comment"),
- "u",
- "Field set in create() should be stamped as user-supplied",
+ cls.env.cache.invalidate()
+ cls.env.registry.clear_cache()
+
+ # ------------------------------------------------------------------
+ # Stamping on user paths
+ # ------------------------------------------------------------------
+ def test_create_with_value_stamps_user(self):
+ partner = self.env["res.partner"].create(
+ {"name": "Created With Comment", "comment": "manual note"}
)
+ entry = (partner._provenance or {}).get("comment")
+ self.assertIsInstance(entry, dict, "Provenance entries are dicts now")
+ self.assertEqual(entry["s"], "u")
+ self.assertEqual(entry["b"], self.env.user.login)
+ self.assertIsInstance(entry["t"], int)
self.assertTrue(partner._user_set("comment"))
- def test_write_stamps_user(self):
+ def test_create_without_value_does_not_stamp(self):
+ """Implicit default — absence of an entry means the field is
+ still at its default. We never stamp the default state; it
+ keeps the JSON column compact."""
partner = self.env["res.partner"].create({"name": "No Comment"})
- self.assertFalse((partner._provenance or {}).get("comment"))
+ self.assertNotIn(
+ "comment",
+ (partner._provenance or {}),
+ "Default state must be implicit (no entry).",
+ )
+ self.assertFalse(partner._user_set("comment"))
+
+ def test_write_stamps_user(self):
+ partner = self.env["res.partner"].create({"name": "Late Writer"})
partner.write({"comment": "user-typed"})
- self.assertEqual(
- (partner._provenance or {}).get("comment"),
- "u",
- "Subsequent user write() should overwrite provenance to 'u'",
+ entry = (partner._provenance or {}).get("comment")
+ self.assertEqual(entry["s"], "u")
+ self.assertEqual(entry["b"], self.env.user.login)
+
+ # ------------------------------------------------------------------
+ # Rule / cascade stamping (public API)
+ # ------------------------------------------------------------------
+ def test_stamp_provenance_rule(self):
+ partner = self.env["res.partner"].create({"name": "Cascade Target"})
+ partner._stamp_provenance(
+ ["comment"],
+ source="r",
+ by="sot.cascade",
+ rule="Sale Order Type cascade",
)
+ entry = (partner._provenance or {}).get("comment")
+ self.assertEqual(entry["s"], "r")
+ self.assertEqual(entry["b"], "sot.cascade")
+ self.assertEqual(entry["r"], "Sale Order Type cascade")
+ # `_user_set` returns False for rule-stamped entries — that's the
+ # whole point: cascade values stay re-computable on next change.
+ self.assertFalse(partner._user_set("comment"))
- def test_user_set_via_explicit_stamp(self):
- """When the provenance map records 'u', _user_set must return True
- regardless of the _origin comparison."""
+ def test_stamp_provenance_user_via_public_api(self):
partner = self.env["res.partner"].create({"name": "Anchored"})
- partner._stamp_provenance_keys(["comment"], source="u")
+ partner._stamp_provenance(["comment"], source="u", by="dkendall")
self.assertTrue(partner._user_set("comment"))
- def test_untracked_field_not_stamped(self):
- partner = self.env["res.partner"].create({"name": "Tracked Only"})
+ def test_stamp_provenance_rejects_unknown_source(self):
+ partner = self.env["res.partner"].create({"name": "Reject Source"})
+ with self.assertRaises(ValueError):
+ partner._stamp_provenance(["comment"], source="d", by="anything")
+ with self.assertRaises(ValueError):
+ partner._stamp_provenance(["comment"], source="s", by="anything")
+
+ def test_stamp_provenance_requires_by(self):
+ partner = self.env["res.partner"].create({"name": "Reject By"})
+ with self.assertRaises(ValueError):
+ partner._stamp_provenance(["comment"], source="r", by="")
+ with self.assertRaises(ValueError):
+ partner._stamp_provenance(["comment"], source="r", by=None)
+
+ def test_stamp_provenance_skips_untracked_keys(self):
+ partner = self.env["res.partner"].create({"name": "Mixed"})
+ partner._stamp_provenance(["comment", "name"], source="r", by="sot.cascade")
prov = partner._provenance or {}
- self.assertNotIn(
- "name",
- prov,
- "Fields without track_provenance=True must not bloat the JSON map",
+ self.assertIn("comment", prov)
+ self.assertNotIn("name", prov, "Untracked keys must not be stamped")
+
+ def test_user_write_flips_rule_to_user(self):
+ partner = self.env["res.partner"].create({"name": "Override Path"})
+ partner._stamp_provenance(["comment"], source="r", by="sot.cascade")
+ self.assertFalse(partner._user_set("comment"))
+ partner.write({"comment": "I override the rule"})
+ self.assertTrue(partner._user_set("comment"))
+ self.assertEqual((partner._provenance or {})["comment"]["s"], "u")
+
+ # ------------------------------------------------------------------
+ # `_provenance_for` — badge tooltip
+ # ------------------------------------------------------------------
+ def test_provenance_for_default(self):
+ partner = self.env["res.partner"].create({"name": "Default Only"})
+ info = partner._provenance_for("comment")
+ self.assertEqual(info["state"], "default")
+ self.assertIn("Default", info["label"])
+ # The "when" for a default state is the record's create_date,
+ # serving as the proxy for "this default has been in effect
+ # since X" without storing any extra data.
+ self.assertIsNotNone(info["when"])
+
+ def test_provenance_for_user(self):
+ partner = self.env["res.partner"].create({"name": "User State", "comment": "x"})
+ info = partner._provenance_for("comment")
+ self.assertEqual(info["state"], "user")
+ self.assertEqual(info["by"], self.env.user.login)
+ self.assertIn(self.env.user.login, info["label"])
+
+ def test_provenance_for_rule(self):
+ partner = self.env["res.partner"].create({"name": "Rule State"})
+ partner._stamp_provenance(
+ ["comment"],
+ source="r",
+ by="sot.cascade",
+ rule="Sale Order Type cascade",
)
+ info = partner._provenance_for("comment")
+ self.assertEqual(info["state"], "rule")
+ self.assertEqual(info["by"], "sot.cascade")
+ self.assertEqual(info["rule"], "Sale Order Type cascade")
+ self.assertIn("Sale Order Type cascade", info["label"])
+
+ def test_provenance_for_legacy_string_entry(self):
+ """Backward-compat: read a v0.1 bare-string entry."""
+ partner = self.env["res.partner"].create({"name": "Legacy"})
+ # Bypass the public API to inject the legacy shape directly.
+ partner.with_context(_prov_skip=True).write({"_provenance": {"comment": "u"}})
+ self.assertTrue(partner._user_set("comment"))
+ info = partner._provenance_for("comment")
+ self.assertEqual(info["state"], "user")
+ # No `by` was stored in the legacy form; tooltip degrades to
+ # "Set by user system".
+ self.assertEqual(info["by"], "system")
+
+ # ------------------------------------------------------------------
+ # Track-set caching and untracked-field hygiene
+ # ------------------------------------------------------------------
+ def test_untracked_field_not_stamped(self):
+ partner = self.env["res.partner"].create({"name": "Tracked Only"})
+ self.assertNotIn("name", partner._provenance or {})
def test_cache_invalidated_on_flag_toggle(self):
- # The "comment" field is tracked from setUpClass; remove the flag
- # and confirm new writes no longer stamp.
- self.field_comment.write({"track_provenance": False})
+ self._set_track_provenance(self.field_comment, False)
partner = self.env["res.partner"].create(
- {
- "name": "Post-Toggle",
- "comment": "no longer tracked",
- }
- )
- self.assertNotIn(
- "comment",
- (partner._provenance or {}),
- "After toggling track_provenance off, comment should not be stamped",
+ {"name": "Post-Toggle", "comment": "no longer tracked"}
)
- # Restore for any subsequent tests
- self.field_comment.write({"track_provenance": True})
+ self.assertNotIn("comment", partner._provenance or {})
+ self._set_track_provenance(self.field_comment, True)
+ # ------------------------------------------------------------------
+ # Web client integration
+ # ------------------------------------------------------------------
def test_web_read_emits_provenance(self):
partner = self.env["res.partner"].create(
- {
- "name": "WebRead Probe",
- "comment": "anchored",
- }
+ {"name": "WebRead Probe", "comment": "anchored"}
)
rows = partner.web_read({"comment": {}, "name": {}})
self.assertEqual(len(rows), 1)
self.assertIn("_provenance", rows[0])
- self.assertEqual(rows[0]["_provenance"].get("comment"), "u")
+ self.assertEqual(rows[0]["_provenance"]["comment"]["s"], "u")
+
+ # ------------------------------------------------------------------
+ # Timestamp determinism
+ # ------------------------------------------------------------------
+ def test_explicit_when_is_persisted(self):
+ partner = self.env["res.partner"].create({"name": "Frozen Time"})
+ frozen = int(time.time()) - 3600 # one hour ago
+ partner._stamp_provenance(
+ ["comment"], source="r", by="cron.recompute", when=frozen
+ )
+ self.assertEqual((partner._provenance or {})["comment"]["t"], frozen)
+ info = partner._provenance_for("comment")
+ # The ISO timestamp serializes the frozen-in-the-past time.
+ self.assertIn("T", info["when"])
From ea17ec981305c731984be8fe048dd2321323ccc0 Mon Sep 17 00:00:00 2001
From: Don Kendall
Date: Sat, 16 May 2026 19:44:29 -0400
Subject: [PATCH 4/9] [FIX] web_field_provenance: sanitize client provenance
writes + SCSS + dead code
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Self-review findings addressed before opening the PR:
* **Security**: the OWL `onBadgeClick` writes `_provenance` directly
with a client-supplied `b` (login) and `t` (timestamp). The server
accepted this verbatim, so a malicious client could spoof attribution.
Adds `_sanitize_client_provenance` invoked from `write()` whenever
`_provenance` appears in the incoming vals:
- any entry the client provides is rewritten to
`{s:"u", b:env.user.login, t:int(time.time())}`
- any client-asserted `s="r"` entry is dropped — only server-side
cascade callers (via `_stamp_provenance`) may stamp rule
provenance
- non-dict payloads sanitize to `{}` rather than persist junk
Server-side `_stamp_provenance_keys` continues to bypass this path
via the `_prov_skip` context.
* **SCSS**: template emits `o_provenance_default | _rule | _user` but
the stylesheet only colored `_user` and the obsolete `_system`. Adds
rules for `_default` (muted) and `_rule` (success/green), drops the
obsolete `_system` selector.
* **Dead code**: `_logger` was imported but never used; `badge.visible`
state + the `t-if=badge.visible` template branch never toggled. Both
removed.
* **Docs**: `DESCRIPTION.md` and `USAGE.md` still described the v0.1
two-state model (gear / pencil) and missed the new
`_stamp_provenance` public API. Rewritten to describe the three
states, the cascade API, and the sanitization guarantee.
No behavior change for the existing 17 tests; the follow-up commit adds
coverage for the security path and the surfaced gaps.
---
web_field_provenance/README.rst | 130 +++++++++++----
web_field_provenance/models/base_model.py | 38 ++++-
web_field_provenance/readme/DESCRIPTION.md | 42 +++--
web_field_provenance/readme/USAGE.md | 86 +++++++---
.../static/description/index.html | 149 +++++++++++++-----
.../static/src/provenance_badge.esm.js | 6 -
.../static/src/provenance_badge.scss | 11 +-
.../static/src/provenance_badge.xml | 1 -
8 files changed, 341 insertions(+), 122 deletions(-)
diff --git a/web_field_provenance/README.rst b/web_field_provenance/README.rst
index cc6e054b7e10..674b4563124d 100644
--- a/web_field_provenance/README.rst
+++ b/web_field_provenance/README.rst
@@ -28,26 +28,48 @@ Web Field Provenance
|badge1| |badge2| |badge3| |badge4| |badge5|
-Track and surface whether a field value was set by the user or assigned
-by a computed/derived default — Salesforce/SAP-style field provenance
-for Odoo.
+Track and surface whether a field value was set by the user, assigned by
+a rule/cascade/integration, or is still at its default — Salesforce/
+SAP-style field provenance for Odoo.
Solves the OCA-wide bug class where
``compute=..., store=True, readonly=False`` ("computed-writable") fields
silently overwrite manual user overrides when an upstream dependency
changes and the compute re-fires through ``super()``. Provides:
-1. A persistent per-record provenance map (``_provenance``) that records
- which fields were last set by a user vs. assigned by a compute.
-2. A reusable ``_user_set(fname)`` helper for compute methods to gate
- ``super()`` on real user intent rather than chain-residual values.
-3. An OWL ``provenance_m2o`` widget that renders a small badge next to
- opted-in fields: green gear icon = derived default, pencil = user
- override. Clicking the badge anchors the value as user-supplied.
+1. A persistent per-record provenance map (``_provenance``) that
+ records, for every opted-in field, **how** it got its current value.
+ Three states:
+
+ - **default** — no entry. The field is still at the value it had on
+ creation. We never stamp the default state, which keeps the JSON
+ column compact.
+ - **rule / cascade / EDI** —
+ ``{s:"r", b:, t:, r:
In Settings → Technical → Database Structure → Fields, find the field
-you want tracked and tick Track Provenance. Or programmatically:
+and tick Track Provenance. (For fields declared in Python — most
+fields you’ll want to track — the UI write is blocked by Odoo’s
+base-field protection; the documented workaround is a small admin
+wizard, see ROADMAP.)
+
Programmatically (e.g. in a post_init_hook of a downstream module):
-self.env["ir.model.fields"]._get("account.move","invoice_payment_term_id")\
- .write({"track_provenance":True})
+self.env.cr.execute(
+"UPDATE ir_model_fields SET track_provenance = TRUE "
+"WHERE model = %s AND name = %s",
+("sale.order","payment_term_id"),
+)
+self.env.registry.clear_cache()
When a non-user writer (a compute cascade, an EDI inbound, an import
+loader) sets a value, call _stamp_provenance so the badge attributes
+the value to the correct source instead of the env user:
+
+order._stamp_provenance(
+["payment_term_id"],
+source="r",
+by="sot.cascade",# stable writer identifier
+rule="Sale Order Type cascade",# optional human-readable label
+)
+
The badge will then render the green-cog (rule) icon and the hover
+tooltip will read “Set by Sale Order Type cascade”.
A small icon appears next to the field with three states:
-
Green gear — the value came from a default or compute. It may
-change if upstream fields change.
-
Pencil — the user has anchored this value. Recompute cascades will
-respect it.
+
Grey outline — default: field is still at its initial value. No
+entry exists in the provenance map; the badge falls back to
+record.create_date for the tooltip.
+
Green cog — rule / cascade / EDI: the value was set by
+server-side logic (_stamp_provenance called with source="r").
+Tooltip names the writer.
+
Pencil — user: the user has anchored this value. Recompute
+cascades that consult _user_set will respect it.
-
Clicking the icon promotes the current value to user-anchored.
+
Clicking the icon promotes the current value to user-anchored. The
+server sanitizes the click (forces b = env.user.login, drops any
+client-supplied rule provenance) — there is no path for a malicious
+client to spoof the writer identity.
Bugs are tracked on GitHub Issues.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
@@ -507,21 +570,21 @@