From 31a543ef0c2f5f5890591bfb75a545709a72fc09 Mon Sep 17 00:00:00 2001 From: SilvioC2C Date: Thu, 20 Nov 2025 22:44:57 +0100 Subject: [PATCH 1/5] [ADD] base_write_diff --- base_write_diff/README.rst | 191 +++++++ base_write_diff/__init__.py | 1 + base_write_diff/__manifest__.py | 14 + base_write_diff/i18n/base_write_diff.pot | 19 + base_write_diff/models/__init__.py | 1 + base_write_diff/models/base.py | 117 ++++ base_write_diff/pyproject.toml | 3 + base_write_diff/readme/CONTRIBUTORS.md | 1 + base_write_diff/readme/DESCRIPTION.md | 2 + base_write_diff/readme/USAGE.md | 101 ++++ base_write_diff/static/description/icon.png | Bin 0 -> 10254 bytes base_write_diff/static/description/index.html | 524 ++++++++++++++++++ base_write_diff/tests/__init__.py | 1 + base_write_diff/tests/test_base_write_diff.py | 423 ++++++++++++++ 14 files changed, 1398 insertions(+) create mode 100644 base_write_diff/README.rst create mode 100644 base_write_diff/__init__.py create mode 100644 base_write_diff/__manifest__.py create mode 100644 base_write_diff/i18n/base_write_diff.pot create mode 100644 base_write_diff/models/__init__.py create mode 100644 base_write_diff/models/base.py create mode 100644 base_write_diff/pyproject.toml create mode 100644 base_write_diff/readme/CONTRIBUTORS.md create mode 100644 base_write_diff/readme/DESCRIPTION.md create mode 100644 base_write_diff/readme/USAGE.md create mode 100644 base_write_diff/static/description/icon.png create mode 100644 base_write_diff/static/description/index.html create mode 100644 base_write_diff/tests/__init__.py create mode 100644 base_write_diff/tests/test_base_write_diff.py diff --git a/base_write_diff/README.rst b/base_write_diff/README.rst new file mode 100644 index 00000000000..b18633da22b --- /dev/null +++ b/base_write_diff/README.rst @@ -0,0 +1,191 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +Base - Write Diff +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:71745a37619c24019b67f8558eb6c7be1b974ad0031b6ce40892e7eec305bcb3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/17.0/base_write_diff + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-17-0/server-tools-17-0-base_write_diff + :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/server-tools&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows filtering values to update on records according to +whether they are actually different from the records' current values. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Summary** + +This module allows you to update records by filtering out fields whose +values are going to be left unchanged by ``BaseModel.write()``; for +example, let's assume you have: + +.. code:: python + + >>> self + sale.order.line(1,) + >>> self.price_unit + 10.00 + +If you use ``self.write({"price_unit": 10.00})`` or +``self.price_unit = 10.00``, Odoo may end up executing unnecessary +operations, like triggering the update on the field, recompute computed +fields that depend on ``price_unit``, and so on, even if the value is +actually unchanged. + +By using this module, you can prevent all of that. + +You can use this module in 3 different ways. All of them require you to +add this module as a dependency of your module. + +**1 - Context key ``"write_use_diff_values"``** + +By adding ``write_use_diff_values=True`` to the context when updating a +field value, the ``BaseModel.write()`` patch will take care of filtering +out the fields' values that are the same as the record's current ones. + +⚠️ Beware: the context key is propagated down to other ``write()`` calls + +Example: + +.. code:: python + + from odoo import models + + + class ProductTemplate(models.Model): + _inherit = "product.template" + + def write(self, vals): + # Update only fields that are actually different + self = self.with_context(write_use_diff_values=True) + return super().write(vals) + + + class ProductProduct(models.Model): + _inherit = "product.product" + + def update_code_if_necessary(self, code: str): + # Update ``default_code`` only if different from the current value + self.with_context(write_use_diff_values=True).default_code = code + +**2 - Method ``BaseModel.write_diff()``** + +It is the same as calling ``write()``, but it automatically enables the +``"write_use_diff_values"`` context flag: ``self.write_diff(vals)`` is a +shortcut for +``self.with_context(write_use_diff_values=True).write(vals)`` + +⚠️ Beware: the context key is propagated down to other ``write()`` calls + +**3 - Method ``BaseModel._get_write_diff_values(vals)``** + +This method accepts a write-like ``dict`` as param, and returns a new +``dict`` made of the fields who will actually update the record's +values. This allows for a more flexible and customizable behavior than +the context key usage, because: + +- you'll be able to filter out specific fields, instead of filtering out + all the fields whose values won't be changed after the update; +- you'll be able to execute the filtering on specific models, instead of + executing it on all the models involved in the stack of ``write()`` + calls from the first usage of the context key down to the base method + ``BaseModel.write()``. + +Example: + +.. code:: python + + from collections import defaultdict + + from odoo import api, models + from odoo.tools.misc import frozendict + + + class ProductProduct(models.Model): + _inherit = "product.product" + + def write(self, vals): + # OVERRIDE: ``odoo.addons.product.models.product_product.Product.write()`` + # override will clear the whole registry cache if either 'active' or + # 'product_template_attribute_value_ids' are found in the ``vals`` dictionary: + # remove them unless it's necessary to update them + fnames = {"active", "product_template_attribute_value_ids"} + if vals_to_check := {f: vals.pop(f) for f in fnames.intersection(vals)}: + groups = defaultdict(lambda: self.browse()) + for prod in self: + groups[frozendict(prod._get_write_diff_values(vals_to_check))] += prod + for diff_vals, prods in groups.items(): + if res_vals := (vals | dict(diff_vals)): + super(ProductProduct, prods).write(res_vals) + return True + return super().write(vals) + +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 +------- + +* Camptocamp + +Contributors +------------ + +- Silvio Gregorini + +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. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_write_diff/__init__.py b/base_write_diff/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/base_write_diff/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_write_diff/__manifest__.py b/base_write_diff/__manifest__.py new file mode 100644 index 00000000000..1fb1984a8ce --- /dev/null +++ b/base_write_diff/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Base - Write Diff", + "summary": "Prevents updates on fields whose values won't change anyway", + "version": "17.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Hidden", + "website": "https://github.com/OCA/server-tools", + "installable": True, + "depends": ["base", "web"], +} diff --git a/base_write_diff/i18n/base_write_diff.pot b/base_write_diff/i18n/base_write_diff.pot new file mode 100644 index 00000000000..55c54821814 --- /dev/null +++ b/base_write_diff/i18n/base_write_diff.pot @@ -0,0 +1,19 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_write_diff +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_write_diff +#: model:ir.model,name:base_write_diff.model_base +msgid "Base" +msgstr "" diff --git a/base_write_diff/models/__init__.py b/base_write_diff/models/__init__.py new file mode 100644 index 00000000000..0e44449338c --- /dev/null +++ b/base_write_diff/models/__init__.py @@ -0,0 +1 @@ +from . import base diff --git a/base_write_diff/models/base.py b/base_write_diff/models/base.py new file mode 100644 index 00000000000..7a4e927126e --- /dev/null +++ b/base_write_diff/models/base.py @@ -0,0 +1,117 @@ +# Copyright 2025 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import Command, models +from odoo.tools.misc import frozendict + +from odoo.addons.web.models.models import RecordSnapshot + + +class BaseModel(models.BaseModel): + _inherit = "base" + + def write(self, vals): + # OVERRIDE: when using the ``write_use_diff_values`` context key, remove values + # that won't be changed before/after the ``write()`` itself. + # If ``write()`` is called on an empty recordset or with no value, ignore + # everything and shortcut to ``super()``. + if not (self and vals and self.env.context.get("write_use_diff_values")): + return super().write(vals) + recs_by_vals = defaultdict(lambda: self.browse()) + for rec in self: + recs_by_vals[frozendict(rec._get_write_diff_values(vals))] += rec + for rec_vals, recs in recs_by_vals.items(): + if rec_vals: # Don't trigger ``write()`` if there is nothing to update + super(BaseModel, recs).write(dict(rec_vals)) + return True + + def write_diff(self, vals: dict) -> bool: + """Executes a ``write()`` only on fields that actually need to be updated""" + return self.with_context(write_use_diff_values=True).write(vals) + + def _get_write_diff_values(self, vals: dict) -> dict: + """Compares record values with the values to write + + Returns a dictionary containing only the fields that actually needs to be + updated on ``self``, filtering out those which contain a value that is the same + as the current record's field value. + For example: + >>> self.name = "A" + >>> self.code = "a" + >>> self._get_write_diff_values({"name": "A", "code": "a"}) + {} + >>> self._get_write_diff_values({"name": "B", "code": "a"}) + {"name": "B"} + >>> self._get_write_diff_values({"name": "B", "code": "b"}) + {"name": "B", "code": "b"} + """ + self.ensure_one() + diff_values = {} + + # Step 1: group fields according to whether they're multi-relational or not + x2many_fields_values, simple_fields_values = {}, {} + for fname, fvalue in vals.items(): + if self._fields[fname].type in ("one2many", "many2many"): + x2many_fields_values[fname] = fvalue + else: + simple_fields_values[fname] = fvalue + + # Step 2: prepare fields to update by checking simple fields first + if simple_fields_values: + simple_fields_specs = {f: {} for f in simple_fields_values} + snapshot0 = self._do_snapshot({}, simple_fields_specs) + snapshot1 = self._do_snapshot(simple_fields_values, simple_fields_specs) + diff_values.update(snapshot1.diff(snapshot0)) + + # Step 3: prepare fields to update by checking multi-relational fields + # For each multi-relational field, prepare a new list of values by checking + # the original commands: + # - if it's an update command, check whether something actually changes on + # the corecord by calling ``_get_write_diff_values()`` recursively + # - else, add the original command to the new list: all commands except "update" + # will modify the record-corecords relation by creating/[un]linking/deleting + # corecords + # Then, check the new list of values to decide if the field needs updating: + # - at least 1 creation/update => add the full list of commands for simplicity + # - else => check whether the new values will effectively change the + # record-corecords relationship + for fname, fvalues in x2many_fields_values.items(): + # Prepare the new list of commands/values according to the original command + new_fvalues = [] + for fvalue in fvalues: + if fvalue[0] == Command.UPDATE: + cmd, corec_id, corec_vals = fvalue + corec = self.env[self._fields[fname].comodel_name].browse(corec_id) + if corec_diff_vals := corec._get_write_diff_values(corec_vals): + new_fvalues.append((cmd, corec_id, corec_diff_vals)) + else: + new_fvalues.append(fvalue) + # Check whether we actually need to include the new list in the diff values + if any(v[0] in (Command.CREATE, Command.UPDATE) for v in new_fvalues): + diff_values[fname] = new_fvalues + else: + x2many_snapshot0 = self._do_snapshot({}, {fname: {}}) + x2many_snapshot1 = self._do_snapshot({fname: new_fvalues}, {fname: {}}) + if x2many_diff_values := x2many_snapshot1.diff(x2many_snapshot0): + diff_values.update(x2many_diff_values) + + return diff_values + + def _do_snapshot(self, vals: dict, specs: dict) -> "RecordSnapshot": + """Prepares a ``RecordSnapshot`` object with the specified params""" + self.ensure_one() + # Align ``vals`` and ``specs`` to make sure they both contain the same fields: + # - if a field in ``specs`` is missing from ``vals``, we read its current value + # from the record and convert it to a ``write()``-able format to prevent cache + # issues and inconsistencies + # - if a field in ``vals`` is missing from ``specs``, we add it with the default + # value of ``{}`` to allow ``RecordSnapshot`` to handle it properly + vals_fnames_not_in_specs = set(vals) - set(specs) + specs_fnames_not_in_vals = set(specs) - set(vals) + for fname in vals_fnames_not_in_specs: + specs[fname] = {} + for fname in specs_fnames_not_in_vals: + vals[fname] = self._fields[fname].convert_to_write(self[fname], self) + return RecordSnapshot(self.new(values=vals, origin=self), fields_spec=specs) diff --git a/base_write_diff/pyproject.toml b/base_write_diff/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/base_write_diff/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_write_diff/readme/CONTRIBUTORS.md b/base_write_diff/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..82b9a91c10e --- /dev/null +++ b/base_write_diff/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Silvio Gregorini \<\> diff --git a/base_write_diff/readme/DESCRIPTION.md b/base_write_diff/readme/DESCRIPTION.md new file mode 100644 index 00000000000..141e6771fc2 --- /dev/null +++ b/base_write_diff/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module allows filtering values to update on records according to whether they are +actually different from the records' current values. diff --git a/base_write_diff/readme/USAGE.md b/base_write_diff/readme/USAGE.md new file mode 100644 index 00000000000..f2ff146cd8e --- /dev/null +++ b/base_write_diff/readme/USAGE.md @@ -0,0 +1,101 @@ +**Summary** + +This module allows you to update records by filtering out fields whose values are going +to be left unchanged by ``BaseModel.write()``; for example, let's assume you have: + +```python +>>> self +sale.order.line(1,) +>>> self.price_unit +10.00 +``` + +If you use ``self.write({"price_unit": 10.00})`` or ``self.price_unit = 10.00``, Odoo +may end up executing unnecessary operations, like triggering the update on the field, +recompute computed fields that depend on ``price_unit``, and so on, even if the value +is actually unchanged. + +By using this module, you can prevent all of that. + +You can use this module in 3 different ways. All of them require you to add this module +as a dependency of your module. + +**1 - Context key ``"write_use_diff_values"``** + +By adding ``write_use_diff_values=True`` to the context when updating a field value, + the ``BaseModel.write()`` patch will take care of filtering out the fields' values + that are the same as the record's current ones. + +⚠️ Beware: the context key is propagated down to other ``write()`` calls + +Example: + +```python +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + def write(self, vals): + # Update only fields that are actually different + self = self.with_context(write_use_diff_values=True) + return super().write(vals) + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def update_code_if_necessary(self, code: str): + # Update ``default_code`` only if different from the current value + self.with_context(write_use_diff_values=True).default_code = code +``` + +**2 - Method ``BaseModel.write_diff()``** + +It is the same as calling ``write()``, but it automatically enables the + ``"write_use_diff_values"`` context flag: ``self.write_diff(vals)`` is a shortcut for + ``self.with_context(write_use_diff_values=True).write(vals)`` + +⚠️ Beware: the context key is propagated down to other ``write()`` calls + +**3 - Method ``BaseModel._get_write_diff_values(vals)``** + +This method accepts a write-like ``dict`` as param, and returns a new ``dict`` made of + the fields who will actually update the record's values. This allows for a more + flexible and customizable behavior than the context key usage, because: + +- you'll be able to filter out specific fields, instead of filtering out all the fields + whose values won't be changed after the update; +- you'll be able to execute the filtering on specific models, instead of executing it + on all the models involved in the stack of ``write()`` calls from the first usage of + the context key down to the base method ``BaseModel.write()``. + +Example: + +```python +from collections import defaultdict + +from odoo import api, models +from odoo.tools.misc import frozendict + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def write(self, vals): + # OVERRIDE: ``odoo.addons.product.models.product_product.Product.write()`` + # override will clear the whole registry cache if either 'active' or + # 'product_template_attribute_value_ids' are found in the ``vals`` dictionary: + # remove them unless it's necessary to update them + fnames = {"active", "product_template_attribute_value_ids"} + if vals_to_check := {f: vals.pop(f) for f in fnames.intersection(vals)}: + groups = defaultdict(lambda: self.browse()) + for prod in self: + groups[frozendict(prod._get_write_diff_values(vals_to_check))] += prod + for diff_vals, prods in groups.items(): + if res_vals := (vals | dict(diff_vals)): + super(ProductProduct, prods).write(res_vals) + return True + return super().write(vals) +``` diff --git a/base_write_diff/static/description/icon.png b/base_write_diff/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Base - Write Diff

+ +

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

+

This module allows filtering values to update on records according to +whether they are actually different from the records’ current values.

+

Table of contents

+ +
+

Usage

+

Summary

+

This module allows you to update records by filtering out fields whose +values are going to be left unchanged by BaseModel.write(); for +example, let’s assume you have:

+
+>>> self
+sale.order.line(1,)
+>>> self.price_unit
+10.00
+
+

If you use self.write({"price_unit": 10.00}) or +self.price_unit = 10.00, Odoo may end up executing unnecessary +operations, like triggering the update on the field, recompute computed +fields that depend on price_unit, and so on, even if the value is +actually unchanged.

+

By using this module, you can prevent all of that.

+

You can use this module in 3 different ways. All of them require you to +add this module as a dependency of your module.

+

1 - Context key ``”write_use_diff_values”``

+

By adding write_use_diff_values=True to the context when updating a +field value, the BaseModel.write() patch will take care of filtering +out the fields’ values that are the same as the record’s current ones.

+

⚠️ Beware: the context key is propagated down to other write() calls

+

Example:

+
+from odoo import models
+
+
+class ProductTemplate(models.Model):
+    _inherit = "product.template"
+
+    def write(self, vals):
+        # Update only fields that are actually different
+        self = self.with_context(write_use_diff_values=True)
+        return super().write(vals)
+
+
+class ProductProduct(models.Model):
+    _inherit = "product.product"
+
+    def update_code_if_necessary(self, code: str):
+        # Update ``default_code`` only if different from the current value
+        self.with_context(write_use_diff_values=True).default_code = code
+
+

2 - Method ``BaseModel.write_diff()``

+

It is the same as calling write(), but it automatically enables the +"write_use_diff_values" context flag: self.write_diff(vals) is a +shortcut for +self.with_context(write_use_diff_values=True).write(vals)

+

⚠️ Beware: the context key is propagated down to other write() calls

+

3 - Method ``BaseModel._get_write_diff_values(vals)``

+

This method accepts a write-like dict as param, and returns a new +dict made of the fields who will actually update the record’s +values. This allows for a more flexible and customizable behavior than +the context key usage, because:

+
    +
  • you’ll be able to filter out specific fields, instead of filtering out +all the fields whose values won’t be changed after the update;
  • +
  • you’ll be able to execute the filtering on specific models, instead of +executing it on all the models involved in the stack of write() +calls from the first usage of the context key down to the base method +BaseModel.write().
  • +
+

Example:

+
+from collections import defaultdict
+
+from odoo import api, models
+from odoo.tools.misc import frozendict
+
+
+class ProductProduct(models.Model):
+    _inherit = "product.product"
+
+    def write(self, vals):
+        # OVERRIDE: ``odoo.addons.product.models.product_product.Product.write()``
+        # override will clear the whole registry cache if either 'active' or
+        # 'product_template_attribute_value_ids' are found in the ``vals`` dictionary:
+        # remove them unless it's necessary to update them
+        fnames = {"active", "product_template_attribute_value_ids"}
+        if vals_to_check := {f: vals.pop(f) for f in fnames.intersection(vals)}:
+            groups = defaultdict(lambda: self.browse())
+            for prod in self:
+                groups[frozendict(prod._get_write_diff_values(vals_to_check))] += prod
+            for diff_vals, prods in groups.items():
+                if res_vals := (vals | dict(diff_vals)):
+                    super(ProductProduct, prods).write(res_vals)
+            return True
+        return super().write(vals)
+
+
+
+

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

+
    +
  • Camptocamp
  • +
+
+
+

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.

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/base_write_diff/tests/__init__.py b/base_write_diff/tests/__init__.py new file mode 100644 index 00000000000..aa320247467 --- /dev/null +++ b/base_write_diff/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_write_diff diff --git a/base_write_diff/tests/test_base_write_diff.py b/base_write_diff/tests/test_base_write_diff.py new file mode 100644 index 00000000000..a630dbbccab --- /dev/null +++ b/base_write_diff/tests/test_base_write_diff.py @@ -0,0 +1,423 @@ +# Copyright 2025 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from logging import getLogger + +from odoo_test_helper import FakeModelLoader + +from odoo import api, fields, models +from odoo.tests import TransactionCase +from odoo.tools.misc import mute_logger + +from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT + + +class TestRecordDiffCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Setup env + cls.env = cls.env["base"].with_context(**DISABLED_MAIL_CONTEXT).env + + # ``_register_hook()`` is usually called at the end of the test process, but + # we need to be able to test it here + cls.env["base"]._register_hook() + + # Load test model + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + + class BWDTestModel(models.Model): + _name = "bwd.test.model" + _description = "Base Write Diff - Test Model" + _test_logger = getLogger("bwd.test.model.log") + + # To test a non-relational field + name = fields.Char() + # To test single-relational fields + m2o_id = fields.Many2one("bwd.test.model") + # To test multi-relational fields + o2m_ids = fields.One2many("bwd.test.model", inverse_name="m2o_id") + m2m_ids = fields.Many2many("bwd.test.model", "test_rel", "id_1", "id_2") + # To test computed fields + # ``perimeter``: computed, stored field that depends on stored fields + # ``area``: computed, non-stored field that depends on stored fields + # ``volume``: computed, non-stored field that depends on non-stored fields + length = fields.Integer() # pylint: disable=W8105 (Pylint complains this?) + width = fields.Integer() + height = fields.Integer() + perimeter = fields.Integer(compute="_compute_perimeter", store=True) + area = fields.Integer(compute="_compute_area", store=False) + volume = fields.Integer(compute="_compute_volume", store=False) + + @api.depends("length", "width") + def _compute_perimeter(self): + self._test_logger.warning("Computing perimeter") + for rec in self: + rec.perimeter = 2 * (rec.length + rec.width) + + @api.depends("length", "width") + def _compute_area(self): + self._test_logger.warning("Computing area") + for rec in self: + rec.area = rec.length * rec.width + + @api.depends("area", "height") + def _compute_volume(self): + self._test_logger.warning("Computing volume") + for rec in self: + rec.volume = rec.area * rec.height + + cls.loader.update_registry([BWDTestModel]) + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def _create_records(self, count=1): + records = self.env["bwd.test.model"].create([{} for _ in range(1, count + 1)]) + for rec in records: + rec.name = f"Record {rec.id}" + return records + + +class TestRecordDiff(TestRecordDiffCommon): + @mute_logger("bwd.test.model.log") + def test_00_get_write_diff_values_simple(self): + """Test ``_get_write_diff_values()`` on fields that are not multi-relational""" + record = self._create_records() + # Try to write the same value on non-relational field + # => ``_get_write_diff_values()`` returns an empty dict + vals = {"name": record.name} + self.assertEqual(record._get_write_diff_values(vals), {}) + # Try to write another value on non-relational field + # => ``_get_write_diff_values()`` returns the same dict + vals = {"name": record.name + " something else"} + self.assertEqual(record._get_write_diff_values(vals), vals) + # Try to write the same value on M2O field + # => ``_get_write_diff_values()`` returns an empty dict + vals = {"m2o_id": record.m2o_id.id} + self.assertEqual(record._get_write_diff_values(vals), {}) + # Try to write another value on M2O field + # => ``_get_write_diff_values()`` returns the same dict + vals = {"m2o_id": self._create_records().id} + self.assertEqual(record._get_write_diff_values(vals), vals) + + @mute_logger("bwd.test.model.log") + def test_10_get_write_diff_values_x2many_command_create(self): + """Test ``_get_write_diff_values()`` on fields.Command.create() + + ``_get_write_diff_values()`` always returns the original dict, even after the + corecords are actually created (because the values will create a new, different + corecord if used on ``write()`` again) + """ + record = self._create_records() + vals = { + "o2m_ids": [fields.Command.create({"name": "O2M Co-record"})], + "m2m_ids": [fields.Command.create({"name": "M2M Co-record"})], + } + self.assertEqual(record._get_write_diff_values(vals), vals) + record.write(vals) # Do the real update => the diff is not empty anyway + self.assertEqual(record._get_write_diff_values(vals), vals) + + @mute_logger("bwd.test.model.log") + def test_11_get_write_diff_values_x2many_command_update(self): + """Test ``_get_write_diff_values()`` on fields.Command.update() + + ``_get_write_diff_values()`` returns only the subset of IDs/values that should + be updated + """ + record = self._create_records() + # Create and assign 2 corecords to each X2M field + record.o2m_ids = o2m_corecords = self._create_records(2) + record.m2m_ids = m2m_corecords = self._create_records(2) + # Set vals to update 1 corecord on each X2M field + vals = { + "o2m_ids": [ + fields.Command.update(o2m_corecords[0].id, {"name": "O2M Corec"}), + fields.Command.update( + o2m_corecords[1].id, {"name": o2m_corecords[1].name} + ), + ], + "m2m_ids": [ + fields.Command.update( + m2m_corecords[0].id, {"name": m2m_corecords[0].name} + ), + fields.Command.update(m2m_corecords[1].id, {"name": "M2M Corec"}), + ], + } + # The diff should include only the IDs we want to update, and the fields we are + # actually different on them + self.assertEqual( + record._get_write_diff_values(vals), + { + "o2m_ids": [ + fields.Command.update(o2m_corecords[0].id, {"name": "O2M Corec"}) + ], + "m2m_ids": [ + fields.Command.update(m2m_corecords[1].id, {"name": "M2M Corec"}) + ], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_12_get_write_diff_values_x2many_command_delete(self): + """Test ``_get_write_diff_values()`` on fields.Command.delete() + + ``_get_write_diff_values()`` returns only the subset of IDs that should be + deleted/unlinked + """ + record = self._create_records() + # Create and assign 2 corecords to each X2M field + record.o2m_ids = o2m_corecords = self._create_records(2) + record.m2m_ids = m2m_corecords = self._create_records(2) + # Set vals to delete 1 corecord in each X2M field + vals = { + "o2m_ids": [fields.Command.delete(o2m_corecords[0].id)], + "m2m_ids": [fields.Command.delete(m2m_corecords[1].id)], + } + # The diff should include only the IDs we want to delete + self.assertEqual( + record._get_write_diff_values(vals), + # Odoo assigns command "delete" or "unlink" according to the field type + # and its definition (not important for our purposes here) + { + "o2m_ids": [fields.Command.delete(o2m_corecords[0].id)], + "m2m_ids": [fields.Command.unlink(m2m_corecords[1].id)], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_13_get_write_diff_values_x2many_command_unlink(self): + """Test ``_get_write_diff_values()`` on fields.Command.unlink() + + ``_get_write_diff_values()`` returns only the subset of IDs that should be + deleted/unlinked + """ + record = self._create_records() + # Create and assign 2 corecords to each X2M field + record.o2m_ids = o2m_corecords = self._create_records(2) + record.m2m_ids = m2m_corecords = self._create_records(2) + # Set vals to unlink 1 corecord in each X2M field + vals = { + "o2m_ids": [fields.Command.unlink(o2m_corecords[0].id)], + "m2m_ids": [fields.Command.unlink(m2m_corecords[1].id)], + } + # The diff should include only the IDs we want to unlink + self.assertEqual( + record._get_write_diff_values(vals), + # Odoo assigns command "delete" or "unlink" according to the field type + # and its definition (not important for our purposes here) + { + "o2m_ids": [fields.Command.delete(o2m_corecords[0].id)], + "m2m_ids": [fields.Command.unlink(m2m_corecords[1].id)], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_14_get_write_diff_values_x2many_command_link(self): + """Test ``_get_write_diff_values()`` on fields.Command.link() + + ``_get_write_diff_values()`` returns only the subset of IDs that should be + linked + """ + record = self._create_records() + # Create 2 corecords + o2m_corecords = self._create_records(2) + m2m_corecords = self._create_records(2) + # Assign 1 corecord to each X2M field + record.write( + { + "o2m_ids": [fields.Command.set(o2m_corecords[0].ids)], + "m2m_ids": [fields.Command.set(m2m_corecords[1].ids)], + } + ) + # Set vals to link all corecords on each X2M field + vals = { + "o2m_ids": [fields.Command.link(i) for i in o2m_corecords.ids], + "m2m_ids": [fields.Command.link(i) for i in m2m_corecords.ids], + } + # The diff should include only the IDs we want to link that are not already + # linked + self.assertEqual( + record._get_write_diff_values(vals), + # Odoo will update the commands to include the {"id": corecord.id} in them + { + "o2m_ids": [ + ( + fields.Command.LINK, + o2m_corecords[1].id, + {"id": o2m_corecords[1].id}, + ) + ], + "m2m_ids": [ + ( + fields.Command.LINK, + m2m_corecords[0].id, + {"id": m2m_corecords[0].id}, + ) + ], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_15_get_write_diff_values_x2many_command_clear(self): + """Test ``_get_write_diff_values()`` on fields.Command.clear() + + ``_get_write_diff_values()`` returns only the subset of IDs that should be + deleted/unlinked + """ + record = self._create_records() + # Create and assign 2 corecords to each X2M field + record.o2m_ids = o2m_corecords = self._create_records(2) + record.m2m_ids = m2m_corecords = self._create_records(2) + # Set vals to clear each X2M field + vals = { + "o2m_ids": [fields.Command.clear()], + "m2m_ids": [fields.Command.clear()], + } + self.assertEqual( + record._get_write_diff_values(vals), + # Odoo assigns command "delete" or "unlink" according to the field type + # and its definition (not important for our purposes here) + { + "o2m_ids": [fields.Command.delete(i) for i in o2m_corecords.ids], + "m2m_ids": [fields.Command.unlink(i) for i in m2m_corecords.ids], + }, + ) + record.write(vals) # Do the real update => the diff should be empty now + self.assertEqual(record._get_write_diff_values(vals), {}) + + @mute_logger("bwd.test.model.log") + def test_16_get_write_diff_values_x2many_command_set(self): + """Test ``_get_write_diff_values()`` on fields.Command.set() + + ``_get_write_diff_values()`` behavior depends on various cases + """ + record = self._create_records() + # Create 3 corecords for each X2M field + o2m_corecords = self._create_records(3) + m2m_corecords = self._create_records(3) + + # Case 1: + # - X2M fields contain no corecords + # - we want to assign them some corecords + # => ``_get_write_diff_values()`` should return a ``fields.Command.link()`` + # command for each corecord to add + self.assertEqual( + record._get_write_diff_values( + { + "o2m_ids": [fields.Command.set(o2m_corecords.ids)], + "m2m_ids": [fields.Command.set(m2m_corecords.ids)], + }, + ), + # Odoo will update the commands to "link", and it will add the + # {"id": corecord.id} in them + { + "o2m_ids": [ + (fields.Command.LINK, i, {"id": i}) for i in o2m_corecords.ids + ], + "m2m_ids": [ + (fields.Command.LINK, i, {"id": i}) for i in m2m_corecords.ids + ], + }, + ) + + # Case 2: + # - X2M fields contain some corecords + # - we want to replace them with different corecords + # => ``_get_write_diff_values()`` should return a + # ``fields.Command.[delete|unlink]()`` command for each corecord to remove, + # and a ``fields.Command.link()`` command for each corecord to add + record.o2m_ids = o2m_corecords[:1] + record.m2m_ids = m2m_corecords[:2] + self.assertEqual( + record._get_write_diff_values( + { + "o2m_ids": [fields.Command.set(o2m_corecords[1:].ids)], + "m2m_ids": [fields.Command.set(m2m_corecords[2:].ids)], + }, + ), + # Odoo will update the commands to "unlink", "delete" and "link" (with the + # {"id": corecord.id} in the "link" ones) + { + "o2m_ids": [ + (fields.Command.DELETE, i, 0) for i in o2m_corecords[:1].ids + ] + + [(fields.Command.LINK, i, {"id": i}) for i in o2m_corecords[1:].ids], + "m2m_ids": [ + (fields.Command.UNLINK, i, 0) for i in m2m_corecords[:2].ids + ] + + [(fields.Command.LINK, i, {"id": i}) for i in m2m_corecords[2:].ids], + }, + ) + + # Case 3: + # - X2M fields contain some corecords + # - we want to reassign the same corecords + # => ``_get_write_diff_values()`` should return nothing + record.o2m_ids = o2m_corecords + record.m2m_ids = m2m_corecords + self.assertEqual( + record._get_write_diff_values( + { + "o2m_ids": [fields.Command.set(o2m_corecords.ids)], + "m2m_ids": [fields.Command.set(m2m_corecords.ids)], + }, + ), + {}, + ) + + # Case 4: + # - X2M fields contain some corecords + # - we want to remove all corecords + # => ``_get_write_diff_values()`` should return a + # ``fields.Command.[delete|unlink]()`` command for each linked corecord + self.assertEqual( + record._get_write_diff_values( + { + "o2m_ids": [fields.Command.set([])], + "m2m_ids": [fields.Command.set([])], + }, + ), + # Odoo will update the commands to "unlink" and "delete" + { + "o2m_ids": [(fields.Command.DELETE, i, 0) for i in o2m_corecords.ids], + "m2m_ids": [(fields.Command.UNLINK, i, 0) for i in m2m_corecords.ids], + }, + ) + + # pylint: disable=W0104 + def test_20_write_diff_computed_fields(self): + """Checks cache behavior for computed fields when diff-writing their deps""" + # Prepare the record, its fields values and the cache + record = self._create_records() + vals = {"length": 5, "width": 3, "height": 2} + record.write(vals) + fnames = ("perimeter", "area", "volume") + for fname in fnames: + with mute_logger("bwd.test.model.log"): + record[fname] # Dummy read: set fields in cache + + # Use ``write`` w/ the same values: Odoo will need to recompute the computed + # fields values as soon as they're read + record.write(vals) + for fname in fnames: + with self.assertLogs("bwd.test.model.log", level="WARNING"): + record[fname] # Dummy read: check the compute method is triggered + + # Use ``write_diff`` w/ the same values: Odoo won't need to recompute the + # computed fields values + record.write_diff(vals) + for fname in fnames: + with self.assertNoLogs("bwd.test.model.log", level="WARNING"): + record[fname] # Dummy read: check the compute method is not triggered From 8dd26b2d5cd0c8eababcbd933847a2cd2266e9b7 Mon Sep 17 00:00:00 2001 From: mymage Date: Tue, 9 Dec 2025 08:01:20 +0000 Subject: [PATCH 2/5] Added translation using Weblate (Italian) --- base_write_diff/i18n/it.po | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 base_write_diff/i18n/it.po diff --git a/base_write_diff/i18n/it.po b/base_write_diff/i18n/it.po new file mode 100644 index 00000000000..a47e0f9430d --- /dev/null +++ b/base_write_diff/i18n/it.po @@ -0,0 +1,22 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_write_diff +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-12-09 12:43+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: base_write_diff +#: model:ir.model,name:base_write_diff.model_base +msgid "Base" +msgstr "Base" From 23e56bd5b582edae517fafc1b88a39f80b4ebd08 Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Fri, 12 Dec 2025 13:26:27 +0200 Subject: [PATCH 3/5] [MIG] base_write_diff: Migration to 18.0 --- base_write_diff/README.rst | 16 ++++------ base_write_diff/__manifest__.py | 2 +- base_write_diff/static/description/index.html | 30 ++++++++----------- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/base_write_diff/README.rst b/base_write_diff/README.rst index b18633da22b..90b2e3c9c56 100644 --- a/base_write_diff/README.rst +++ b/base_write_diff/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================= Base - Write Diff ================= @@ -17,17 +13,17 @@ Base - Write Diff .. |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/license-AGPL--3-blue.png +.. |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%2Fserver--tools-lightgray.png?logo=github - :target: https://github.com/OCA/server-tools/tree/17.0/base_write_diff + :target: https://github.com/OCA/server-tools/tree/18.0/base_write_diff :alt: OCA/server-tools .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/server-tools-17-0/server-tools-17-0-base_write_diff + :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-base_write_diff :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/server-tools&target_branch=17.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=18.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -156,7 +152,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -186,6 +182,6 @@ 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. -This module is part of the `OCA/server-tools `_ project on GitHub. +This module is part of the `OCA/server-tools `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_write_diff/__manifest__.py b/base_write_diff/__manifest__.py index 1fb1984a8ce..434cbf281e8 100644 --- a/base_write_diff/__manifest__.py +++ b/base_write_diff/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Base - Write Diff", "summary": "Prevents updates on fields whose values won't change anyway", - "version": "17.0.1.0.0", + "version": "18.0.1.0.0", "author": "Camptocamp, Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Hidden", diff --git a/base_write_diff/static/description/index.html b/base_write_diff/static/description/index.html index 7d8ed2850b7..b068ba559a6 100644 --- a/base_write_diff/static/description/index.html +++ b/base_write_diff/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Base - Write Diff -
+
+

Base - Write Diff

- - -Odoo Community Association - -
-

Base - Write Diff

-

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

+

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

This module allows filtering values to update on records according to whether they are actually different from the records’ current values.

Table of contents

@@ -391,7 +386,7 @@

Base - Write Diff

-

Usage

+

Usage

Summary

This module allows you to update records by filtering out fields whose values are going to be left unchanged by BaseModel.write(); for @@ -484,29 +479,29 @@

Usage

-

Bug Tracker

+

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.

+feedback.

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -514,11 +509,10 @@

Maintainers

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.

-

This module is part of the OCA/server-tools project on GitHub.

+

This module is part of the OCA/server-tools project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
From f3ec33786fefd6a7feab3bf5104351ba845e538e Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Fri, 12 Dec 2025 13:43:44 +0200 Subject: [PATCH 4/5] [IMP] base_write_diff: pre-commit auto fixes --- base_write_diff/README.rst | 16 ++++++---- base_write_diff/static/description/index.html | 30 +++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/base_write_diff/README.rst b/base_write_diff/README.rst index 90b2e3c9c56..a4d544f0638 100644 --- a/base_write_diff/README.rst +++ b/base_write_diff/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ================= Base - Write Diff ================= @@ -13,17 +17,17 @@ Base - Write Diff .. |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 +.. |badge2| image:: https://img.shields.io/badge/license-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%2Fserver--tools-lightgray.png?logo=github - :target: https://github.com/OCA/server-tools/tree/18.0/base_write_diff + :target: https://github.com/OCA/server-tools/tree/19.0/base_write_diff :alt: OCA/server-tools .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-base_write_diff + :target: https://translation.odoo-community.org/projects/server-tools-19-0/server-tools-19-0-base_write_diff :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/server-tools&target_branch=18.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=19.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -152,7 +156,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -182,6 +186,6 @@ 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. -This module is part of the `OCA/server-tools `_ project on GitHub. +This module is part of the `OCA/server-tools `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_write_diff/static/description/index.html b/base_write_diff/static/description/index.html index b068ba559a6..a40f7a17420 100644 --- a/base_write_diff/static/description/index.html +++ b/base_write_diff/static/description/index.html @@ -3,7 +3,7 @@ -Base - Write Diff +README.rst -
-

Base - Write Diff

+
+ + +Odoo Community Association + +
+

Base - Write Diff

-

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

+

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

This module allows filtering values to update on records according to whether they are actually different from the records’ current values.

Table of contents

@@ -386,7 +391,7 @@

Base - Write Diff

-

Usage

+

Usage

Summary

This module allows you to update records by filtering out fields whose values are going to be left unchanged by BaseModel.write(); for @@ -479,29 +484,29 @@

Usage

-

Bug Tracker

+

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.

+feedback.

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -509,10 +514,11 @@

Maintainers

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.

-

This module is part of the OCA/server-tools project on GitHub.

+

This module is part of the OCA/server-tools project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
From c9735a8a12cc4ef8d7930e8aaca1554a43bb0df4 Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Fri, 12 Dec 2025 13:45:41 +0200 Subject: [PATCH 5/5] [MIG] base_write_diff: Migration to 19.0 --- base_write_diff/__manifest__.py | 2 +- base_write_diff/tests/test_base_write_diff.py | 20 ++++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/base_write_diff/__manifest__.py b/base_write_diff/__manifest__.py index 434cbf281e8..716b3bb271c 100644 --- a/base_write_diff/__manifest__.py +++ b/base_write_diff/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Base - Write Diff", "summary": "Prevents updates on fields whose values won't change anyway", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "author": "Camptocamp, Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Hidden", diff --git a/base_write_diff/tests/test_base_write_diff.py b/base_write_diff/tests/test_base_write_diff.py index a630dbbccab..183681b96df 100644 --- a/base_write_diff/tests/test_base_write_diff.py +++ b/base_write_diff/tests/test_base_write_diff.py @@ -3,9 +3,8 @@ from logging import getLogger -from odoo_test_helper import FakeModelLoader - from odoo import api, fields, models +from odoo.orm.model_classes import add_to_registry from odoo.tests import TransactionCase from odoo.tools.misc import mute_logger @@ -24,9 +23,6 @@ def setUpClass(cls): cls.env["base"]._register_hook() # Load test model - cls.loader = FakeModelLoader(cls.env, cls.__module__) - cls.loader.backup_registry() - class BWDTestModel(models.Model): _name = "bwd.test.model" _description = "Base Write Diff - Test Model" @@ -43,7 +39,7 @@ class BWDTestModel(models.Model): # ``perimeter``: computed, stored field that depends on stored fields # ``area``: computed, non-stored field that depends on stored fields # ``volume``: computed, non-stored field that depends on non-stored fields - length = fields.Integer() # pylint: disable=W8105 (Pylint complains this?) + length = fields.Integer() # pylint: disable=attribute-deprecated width = fields.Integer() height = fields.Integer() perimeter = fields.Integer(compute="_compute_perimeter", store=True) @@ -68,12 +64,12 @@ def _compute_volume(self): for rec in self: rec.volume = rec.area * rec.height - cls.loader.update_registry([BWDTestModel]) - - @classmethod - def tearDownClass(cls): - cls.loader.restore_registry() - super().tearDownClass() + add_to_registry(cls.registry, BWDTestModel) + cls.registry._setup_models__(cls.env.cr, ["bwd.test.model"]) + cls.registry.init_models( + cls.env.cr, ["bwd.test.model"], {"models_to_check": True} + ) + cls.addClassCleanup(cls.registry.__delitem__, "bwd.test.model") def _create_records(self, count=1): records = self.env["bwd.test.model"].create([{} for _ in range(1, count + 1)])