diff --git a/operating_unit_isolation/README.rst b/operating_unit_isolation/README.rst new file mode 100644 index 0000000000..001ca90fc0 --- /dev/null +++ b/operating_unit_isolation/README.rst @@ -0,0 +1,168 @@ +======================== +Operating Unit Isolation +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8b1b300b6d9585b5c6702476a01dc638cc0a401514c3ef25c5e774b90387a28d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Foperating--unit-lightgray.png?logo=github + :target: https://github.com/OCA/operating-unit/tree/18.0/operating_unit_isolation + :alt: OCA/operating-unit +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/operating-unit-18-0/operating-unit-18-0-operating_unit_isolation + :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/operating-unit&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +An extension to provide dynamic isolation for operating units across +models. + +This module ensures that users can only select related records that +belong to the same Operating Unit as the current record or its parent +record. It achieves this by seamlessly injecting the current record and +parent record state from the frontend (Odoo 18 OWL framework) into the +backend ``_search`` operations. + +When a user attempts to search or select a record for a relation (e.g., +in a dropdown autocomplete), this module will dynamically filter the +available options based on the ``operating_unit_id`` of the active +record. + +This isolation is triggered automatically for any relation field that +specifies the ``operating_unit_isolation`` attribute. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this functionality in your own module, add the +``operating_unit_isolation`` property to the field definitions in your +models. + +**Isolating based on the current record's Operating Unit:** + +If you have a field on a model and you want the selectable records to be +restricted to the same Operating Unit as the current record, use the +field name of the operating unit as the value: + +.. code:: python + + from odoo import fields, models + + class CustomModel(models.Model): + _name = "custom.model" + + operating_unit_id = fields.Many2one("operating.unit", "Operating Unit") + + # This will restrict selectable partners to those matching the + # 'operating_unit_id' of this custom.model record (or those without an OU) + partner_id = fields.Many2one( + "res.partner", + string="Partner", + operating_unit_isolation="operating_unit_id" + ) + +**Isolating based on a parent record's Operating Unit (One2many +context):** + +If you are working within a One2many line (e.g., ``sale.order.line``) +and want to filter the relation based on the parent model's Operating +Unit, specify the relational field name to the parent and the parent's +operating unit field name separated by a dot: + +.. code:: python + + from odoo import fields, models + + class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + # This will restrict selectable products to those matching the + # 'operating_unit_id' on the parent 'sale.order' (linked via order_id) + product_id = fields.Many2one( + "product.product", + string="Product", + operating_unit_isolation="order_id.operating_unit_id" + ) + +Technical Details +----------------- + +- **Frontend**: Overrides the base OWL ``Field`` component's props to + pass the ``record`` and ``parent_record`` along with their + ``operating_unit_id`` into the RPC context. +- **Backend**: Safely monkey-patches ``models.BaseModel._search`` to + intercept the domain and append an operating unit restriction if the + targeted field specifies the ``operating_unit_isolation`` property. + + - **Dynamic Field Detection**: Automatically detects if the target + model uses a Many2one (``operating_unit_id``) or Many2many + (``operating_unit_ids``) field for its operating unit. + - **Empty Value Handling**: Explicitly includes an ``OR`` condition + allowing records with no assigned operating unit (``False``) to be + universally selectable. + - **Odoo 18 Compatibility Fallback**: Automatically applies the + ``order_id.operating_unit_id`` isolation for ``product_template_id`` + and ``product_id`` on the ``sale.order.line`` model natively, + without requiring the property to be explicitly defined on the + field. + +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 +------- + +* CIT Services + +Contributors +------------ + +- Jordi Riera +- Luc De Meyer +- Solomon Prabu + +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/operating-unit `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/operating_unit_isolation/__init__.py b/operating_unit_isolation/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/operating_unit_isolation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/operating_unit_isolation/__manifest__.py b/operating_unit_isolation/__manifest__.py new file mode 100644 index 0000000000..34ab6db89d --- /dev/null +++ b/operating_unit_isolation/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2016-2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Operating Unit Isolation", + "summary": "An extension to provide isolation for operating units", + "version": "18.0.1.0.0", + "author": "CIT Services, Odoo Community Association (OCA)", + "company": "CIT Services", + "website": "https://github.com/OCA/operating-unit", + "category": "Generic", + "depends": ["base", "operating_unit"], + "data": [], + "assets": { + "web.assets_backend": [ + "operating_unit_isolation/static/src/js/operating_unit_isolation.esm.js" + ], + }, + "license": "AGPL-3", + "installable": True, +} diff --git a/operating_unit_isolation/models/__init__.py b/operating_unit_isolation/models/__init__.py new file mode 100644 index 0000000000..2e9bce4883 --- /dev/null +++ b/operating_unit_isolation/models/__init__.py @@ -0,0 +1 @@ +from . import base_operating_unit_isolation diff --git a/operating_unit_isolation/models/base_operating_unit_isolation.py b/operating_unit_isolation/models/base_operating_unit_isolation.py new file mode 100644 index 0000000000..faee8dc269 --- /dev/null +++ b/operating_unit_isolation/models/base_operating_unit_isolation.py @@ -0,0 +1,71 @@ +# Copyright 2017-2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class BaseOperatingUnitIsolation(models.AbstractModel): + _name = "base_operating_unit_isolation" + _description = "Extend AbstractModel for Operation Unit Isolation" + + def _register_hook(self): + res = super()._register_hook() + ou_patched = getattr(models.BaseModel, "ou_patched", False) + if not ou_patched: + origin_search = models.BaseModel._search + + @api.model + def _ou_search(self, domain, *args, **kwargs): + record = self.env.context.get("record") + + ou_field = False + if "operating_unit_id" in self._fields: + ou_field = "operating_unit_id" + elif "operating_unit_ids" in self._fields: + ou_field = "operating_unit_ids" + + if ( + record + and ou_field + and record.get("_field") + and "_name" in record + and record["_name"] in self.env + and record["_field"] in self.env[record["_name"]]._fields + ): + fld = self.env[record["_name"]]._fields[record["_field"]] + + # For Odoo 18 compatibility, if the field is product_template_id and + # it's on sale.order.line, we can fallback to + # order_id.operating_unit_id if isolation isn't explicitly defined. + isolation_val = getattr(fld, "operating_unit_isolation", False) + if ( + not isolation_val + and record["_name"] == "sale.order.line" + and record["_field"] in ("product_id", "product_template_id") + ): + isolation_val = "order_id.operating_unit_id" + + if isolation_val: + isolation = isolation_val.split(".") + filter_ou = False + if len(isolation) == 2: + parent_record = self.env.context.get("parent_record", {}) + filter_ou = parent_record.get(isolation[1]) + elif len(isolation) == 1: + filter_ou = record.get(isolation[0]) + if filter_ou: + domain = list(domain) if domain else [] + domain.extend( + [ + "|", + (ou_field, "=", False), + (ou_field, "in", [filter_ou]), + ] + ) + + return origin_search(self, domain, *args, **kwargs) + + models.BaseModel._search = _ou_search + models.BaseModel.ou_patched = True + + return res diff --git a/operating_unit_isolation/pyproject.toml b/operating_unit_isolation/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/operating_unit_isolation/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/operating_unit_isolation/readme/CONTRIBUTORS.md b/operating_unit_isolation/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..4d3bd2c1a4 --- /dev/null +++ b/operating_unit_isolation/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- Jordi Riera +- Luc De Meyer \ +- Solomon Prabu \ + diff --git a/operating_unit_isolation/readme/DESCRIPTION.md b/operating_unit_isolation/readme/DESCRIPTION.md new file mode 100644 index 0000000000..adeafed21c --- /dev/null +++ b/operating_unit_isolation/readme/DESCRIPTION.md @@ -0,0 +1,15 @@ +An extension to provide dynamic isolation for operating units across +models. + +This module ensures that users can only select related records that +belong to the same Operating Unit as the current record or its parent +record. It achieves this by seamlessly injecting the current record and +parent record state from the frontend (Odoo 18 OWL framework) into the +backend `_search` operations. + +When a user attempts to search or select a record for a relation (e.g., +in a dropdown autocomplete), this module will dynamically filter the +available options based on the `operating_unit_id` of the active record. + +This isolation is triggered automatically for any relation field that +specifies the `operating_unit_isolation` attribute. diff --git a/operating_unit_isolation/readme/USAGE.md b/operating_unit_isolation/readme/USAGE.md new file mode 100644 index 0000000000..1ea4c8f0df --- /dev/null +++ b/operating_unit_isolation/readme/USAGE.md @@ -0,0 +1,68 @@ +To use this functionality in your own module, add the +`operating_unit_isolation` property to the field definitions in your +models. + +**Isolating based on the current record's Operating Unit:** + +If you have a field on a model and you want the selectable records to be +restricted to the same Operating Unit as the current record, use the +field name of the operating unit as the value: + +``` python +from odoo import fields, models + +class CustomModel(models.Model): + _name = "custom.model" + + operating_unit_id = fields.Many2one("operating.unit", "Operating Unit") + + # This will restrict selectable partners to those matching the + # 'operating_unit_id' of this custom.model record (or those without an OU) + partner_id = fields.Many2one( + "res.partner", + string="Partner", + operating_unit_isolation="operating_unit_id" + ) +``` + +**Isolating based on a parent record's Operating Unit (One2many +context):** + +If you are working within a One2many line (e.g., `sale.order.line`) and +want to filter the relation based on the parent model's Operating Unit, +specify the relational field name to the parent and the parent's +operating unit field name separated by a dot: + +``` python +from odoo import fields, models + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + # This will restrict selectable products to those matching the + # 'operating_unit_id' on the parent 'sale.order' (linked via order_id) + product_id = fields.Many2one( + "product.product", + string="Product", + operating_unit_isolation="order_id.operating_unit_id" + ) +``` + +## Technical Details + +- **Frontend**: Overrides the base OWL `Field` component's props to pass + the `record` and `parent_record` along with their `operating_unit_id` + into the RPC context. +- **Backend**: Safely monkey-patches `models.BaseModel._search` to + intercept the domain and append an operating unit restriction if + the targeted field specifies the `operating_unit_isolation` property. + - **Dynamic Field Detection**: Automatically detects if the target model + uses a Many2one (`operating_unit_id`) or Many2many (`operating_unit_ids`) + field for its operating unit. + - **Empty Value Handling**: Explicitly includes an `OR` condition allowing + records with no assigned operating unit (`False`) to be universally + selectable. + - **Odoo 18 Compatibility Fallback**: Automatically applies the + `order_id.operating_unit_id` isolation for `product_template_id` and + `product_id` on the `sale.order.line` model natively, without requiring + the property to be explicitly defined on the field. diff --git a/operating_unit_isolation/static/description/index.html b/operating_unit_isolation/static/description/index.html new file mode 100644 index 0000000000..573828a4b7 --- /dev/null +++ b/operating_unit_isolation/static/description/index.html @@ -0,0 +1,511 @@ + + + + + +Operating Unit Isolation + + + +
+

Operating Unit Isolation

+ + +

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

+

An extension to provide dynamic isolation for operating units across +models.

+

This module ensures that users can only select related records that +belong to the same Operating Unit as the current record or its parent +record. It achieves this by seamlessly injecting the current record and +parent record state from the frontend (Odoo 18 OWL framework) into the +backend _search operations.

+

When a user attempts to search or select a record for a relation (e.g., +in a dropdown autocomplete), this module will dynamically filter the +available options based on the operating_unit_id of the active +record.

+

This isolation is triggered automatically for any relation field that +specifies the operating_unit_isolation attribute.

+

Table of contents

+ +
+

Usage

+

To use this functionality in your own module, add the +operating_unit_isolation property to the field definitions in your +models.

+

Isolating based on the current record’s Operating Unit:

+

If you have a field on a model and you want the selectable records to be +restricted to the same Operating Unit as the current record, use the +field name of the operating unit as the value:

+
+from odoo import fields, models
+
+class CustomModel(models.Model):
+    _name = "custom.model"
+
+    operating_unit_id = fields.Many2one("operating.unit", "Operating Unit")
+
+    # This will restrict selectable partners to those matching the
+    # 'operating_unit_id' of this custom.model record (or those without an OU)
+    partner_id = fields.Many2one(
+        "res.partner",
+        string="Partner",
+        operating_unit_isolation="operating_unit_id"
+    )
+
+

Isolating based on a parent record’s Operating Unit (One2many +context):

+

If you are working within a One2many line (e.g., sale.order.line) +and want to filter the relation based on the parent model’s Operating +Unit, specify the relational field name to the parent and the parent’s +operating unit field name separated by a dot:

+
+from odoo import fields, models
+
+class SaleOrderLine(models.Model):
+    _inherit = "sale.order.line"
+
+    # This will restrict selectable products to those matching the
+    # 'operating_unit_id' on the parent 'sale.order' (linked via order_id)
+    product_id = fields.Many2one(
+        "product.product",
+        string="Product",
+        operating_unit_isolation="order_id.operating_unit_id"
+    )
+
+
+

Technical Details

+
    +
  • Frontend: Overrides the base OWL Field component’s props to +pass the record and parent_record along with their +operating_unit_id into the RPC context.
  • +
  • Backend: Safely monkey-patches models.BaseModel._search to +intercept the domain and append an operating unit restriction if the +targeted field specifies the operating_unit_isolation property.
      +
    • Dynamic Field Detection: Automatically detects if the target +model uses a Many2one (operating_unit_id) or Many2many +(operating_unit_ids) field for its operating unit.
    • +
    • Empty Value Handling: Explicitly includes an OR condition +allowing records with no assigned operating unit (False) to be +universally selectable.
    • +
    • Odoo 18 Compatibility Fallback: Automatically applies the +order_id.operating_unit_id isolation for product_template_id +and product_id on the sale.order.line model natively, +without requiring the property to be explicitly defined on the +field.
    • +
    +
  • +
+
+
+
+

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

+
    +
  • CIT Services
  • +
+
+
+

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/operating-unit project on GitHub.

+

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

+
+
+
+ + diff --git a/operating_unit_isolation/static/src/js/operating_unit_isolation.esm.js b/operating_unit_isolation/static/src/js/operating_unit_isolation.esm.js new file mode 100644 index 0000000000..a9cc32e778 --- /dev/null +++ b/operating_unit_isolation/static/src/js/operating_unit_isolation.esm.js @@ -0,0 +1,43 @@ +import {Field} from "@web/views/fields/field"; +import {patch} from "@web/core/utils/patch"; + +patch(Field.prototype, { + get fieldComponentProps() { + const props = super.fieldComponentProps; + + if (!("context" in props)) { + return props; + } + + const extraContext = { + record: { + _name: this.props.record.resModel, + _field: this.props.name, + }, + parent_record: {}, + }; + + const data = this.props.record.data; + if (data && data.operating_unit_id) { + extraContext.record.operating_unit_id = Array.isArray( + data.operating_unit_id + ) + ? data.operating_unit_id[0] + : data.operating_unit_id; + } + + if (this.props.record._parentRecord) { + const parentData = this.props.record._parentRecord.data; + if (parentData && parentData.operating_unit_id) { + extraContext.parent_record.operating_unit_id = Array.isArray( + parentData.operating_unit_id + ) + ? parentData.operating_unit_id[0] + : parentData.operating_unit_id; + } + } + + props.context = {...(props.context || {}), ...extraContext}; + return props; + }, +}); diff --git a/operating_unit_isolation/tests/__init__.py b/operating_unit_isolation/tests/__init__.py new file mode 100644 index 0000000000..6cf264a578 --- /dev/null +++ b/operating_unit_isolation/tests/__init__.py @@ -0,0 +1 @@ +from . import test_operating_unit_isolation diff --git a/operating_unit_isolation/tests/test_operating_unit_isolation.py b/operating_unit_isolation/tests/test_operating_unit_isolation.py new file mode 100644 index 0000000000..09364a221d --- /dev/null +++ b/operating_unit_isolation/tests/test_operating_unit_isolation.py @@ -0,0 +1,103 @@ +import logging +import uuid +from unittest.mock import PropertyMock, patch + +from odoo.tests import common + +_logger = logging.getLogger(__name__) + + +class TestOperatingUnitIsolation(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["base_operating_unit_isolation"]._register_hook() + partner_id = cls.env.user.partner_id.id + + unique_code1 = f"OU1_{uuid.uuid4().hex[:8]}" + unique_code2 = f"OU2_{uuid.uuid4().hex[:8]}" + + cls.ou1 = cls.env["operating.unit"].create( + {"name": unique_code1, "code": unique_code1, "partner_id": partner_id} + ) + cls.ou2 = cls.env["operating.unit"].create( + {"name": unique_code2, "code": unique_code2, "partner_id": partner_id} + ) + + def test_search_isolation_current_record(self): + Partner = self.env["res.partner"] + + class DummyField: + operating_unit_isolation = "operating_unit_id" + + mock_fields_dict = Partner._fields.copy() + mock_fields_dict["operating_unit_id"] = DummyField() + mock_fields_dict["test_field"] = DummyField() + + # Setup context simulating OWL frontend request + Partner = Partner.with_context( + record={ + "_name": "res.partner", + "_field": "test_field", + "operating_unit_id": self.ou1.id, + } + ) + + with patch.object( + type(Partner), "_fields", new_callable=PropertyMock + ) as mock_fields: + mock_fields.return_value = mock_fields_dict + with patch.object(type(Partner), "_where_calc") as mock_where_calc: + # Return a dummy query so it doesn't crash + mock_where_calc.return_value = None + try: + Partner._search([]) + except Exception as e: + _logger.debug("Expected dummy error: %s", e) + + self.assertTrue(mock_where_calc.called, "_where_calc should be called") + called_domain = mock_where_calc.call_args[0][0] + + # The domain should have been augmented with operating_unit_id filter + self.assertIn("|", called_domain) + self.assertIn(("operating_unit_id", "=", False), called_domain) + self.assertIn(("operating_unit_id", "in", [self.ou1.id]), called_domain) + + def test_search_isolation_parent_record(self): + Partner = self.env["res.partner"] + + class DummyField: + # Simulate isolating based on parent record + operating_unit_isolation = "order_id.operating_unit_id" + + mock_fields_dict = Partner._fields.copy() + mock_fields_dict["operating_unit_id"] = DummyField() + mock_fields_dict["test_field"] = DummyField() + + # Setup context with parent_record (e.g. from sale.order.line) + Partner = Partner.with_context( + record={ + "_name": "res.partner", + "_field": "test_field", + }, + parent_record={"operating_unit_id": self.ou2.id}, + ) + + with patch.object( + type(Partner), "_fields", new_callable=PropertyMock + ) as mock_fields: + mock_fields.return_value = mock_fields_dict + with patch.object(type(Partner), "_where_calc") as mock_where_calc: + mock_where_calc.return_value = None + try: + Partner._search([]) + except Exception as e: + _logger.debug("Expected dummy error: %s", e) + + self.assertTrue(mock_where_calc.called, "_where_calc should be called") + called_domain = mock_where_calc.call_args[0][0] + + # The domain should have been augmented with parent's OU filter + self.assertIn("|", called_domain) + self.assertIn(("operating_unit_id", "=", False), called_domain) + self.assertIn(("operating_unit_id", "in", [self.ou2.id]), called_domain)