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

+ + +

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

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. +
  3. A reusable _user_set(fname) helper for compute methods to gate +super() on real user intent rather than chain-residual values.
  4. +
  5. 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.
  6. +
+

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

+ +
+

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:

+
+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:

+
+@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:

+
+<field name="invoice_payment_term_id" widget="provenance_m2o"/>
+
+

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

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current 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/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 @@

3. Render the badge in views<

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 @@ -469,6 +475,27 @@

    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:<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.
    • +
    • 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/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: