diff --git a/sale_confirm_group/README.rst b/sale_confirm_group/README.rst new file mode 100644 index 00000000000..702e9b704db --- /dev/null +++ b/sale_confirm_group/README.rst @@ -0,0 +1,102 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================= +Sale Confirmation Group +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:8e2190fe6ab83943e118620c7b3be92f1cc525eabf95b57c350e131544e0f2e6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fsale--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/sale-workflow/tree/19.0/sale_confirm_group + :alt: OCA/sale-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-workflow-19-0/sale-workflow-19-0-sale_confirm_group + :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/sale-workflow&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows configuring a list of groups per-company who are +granted permission to confirm sale orders: + +1. button "Confirm" in sale views is always hidden for users not in + those groups +2. if users outside those groups try to confirm a SO, an error is raised + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +- go to Sales / Configuration / Settings +- scroll until you find the "Use SO Confirmation Groups" checkbox +- if you want to restrict SO confirmation permission: + + - activate the checkbox + - add at least 1 security group to the list below the checkbox + +- if you don't want to restrict SO confirmation permission: + + - deactivate the checkbox, or remove all security groups from the list + below the checkbox + +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 +- Simone Orsi +- Joshua Jan + +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/sale-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_confirm_group/__init__.py b/sale_confirm_group/__init__.py new file mode 100644 index 00000000000..aee8895e7a3 --- /dev/null +++ b/sale_confirm_group/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/sale_confirm_group/__manifest__.py b/sale_confirm_group/__manifest__.py new file mode 100644 index 00000000000..89aacdba1b1 --- /dev/null +++ b/sale_confirm_group/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +{ + "name": "Sale Confirmation Group", + "summary": "Allows configuring a list of groups per-company who are granted" + " permission to confirm sale orders", + "version": "19.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA) ", + "website": "https://github.com/OCA/sale-workflow", + "category": "Sale", + "license": "AGPL-3", + "depends": ["sale"], + "data": [ + # Settings view + "wizards/res_config_settings.xml", + ], + "installable": True, +} diff --git a/sale_confirm_group/i18n/sale_confirm_group.pot b/sale_confirm_group/i18n/sale_confirm_group.pot new file mode 100644 index 00000000000..ad3b5cc84a4 --- /dev/null +++ b/sale_confirm_group/i18n/sale_confirm_group.pot @@ -0,0 +1,64 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_confirm_group +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.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: sale_confirm_group +#: model:ir.model,name:sale_confirm_group.model_res_company +msgid "Companies" +msgstr "" + +#. module: sale_confirm_group +#: model:ir.model,name:sale_confirm_group.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: sale_confirm_group +#: model_terms:ir.ui.view,arch_db:sale_confirm_group.sale_res_config_settings_view_form_inherit +msgid "" +"If the checkbox is flagged and at least 1 group is selected, SO confirmation" +" will be allowed to such groups only. This is company-specific." +msgstr "" + +#. module: sale_confirm_group +#: model:ir.model.fields,field_description:sale_confirm_group.field_res_company__sale_confirmation_group_ids +#: model:ir.model.fields,field_description:sale_confirm_group.field_res_config_settings__sale_confirmation_group_ids +msgid "Sale Confirmation Group" +msgstr "" + +#. module: sale_confirm_group +#: model:ir.model,name:sale_confirm_group.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_confirm_group +#: model_terms:ir.ui.view,arch_db:sale_confirm_group.sale_res_config_settings_view_form_inherit +msgid "Use SO Confirmation Groups" +msgstr "" + +#. module: sale_confirm_group +#: model:ir.model.fields,field_description:sale_confirm_group.field_res_company__use_sale_confirmation_groups +#: model:ir.model.fields,field_description:sale_confirm_group.field_res_config_settings__use_sale_confirmation_groups +msgid "Use Sale Confirmation Groups" +msgstr "" + +#. module: sale_confirm_group +#. odoo-python +#: code:addons/sale_confirm_group/models/sale_order.py:0 +msgid "User %s cannot confirm Sale(s) %s" +msgstr "" + +#. module: sale_confirm_group +#: model:ir.model.fields,field_description:sale_confirm_group.field_sale_order__user_can_confirm +msgid "User Can Confirm" +msgstr "" diff --git a/sale_confirm_group/models/__init__.py b/sale_confirm_group/models/__init__.py new file mode 100644 index 00000000000..eeacbbb6d63 --- /dev/null +++ b/sale_confirm_group/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_company +from . import sale_order diff --git a/sale_confirm_group/models/res_company.py b/sale_confirm_group/models/res_company.py new file mode 100644 index 00000000000..553c28661d6 --- /dev/null +++ b/sale_confirm_group/models/res_company.py @@ -0,0 +1,16 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class Company(models.Model): + _inherit = "res.company" + + use_sale_confirmation_groups = fields.Boolean() + sale_confirmation_group_ids = fields.Many2many( + "res.groups", + relation="res_company_2_res_groups_sales_confirm_rel", + column1="company_id", + column2="group_id", + ) diff --git a/sale_confirm_group/models/sale_order.py b/sale_confirm_group/models/sale_order.py new file mode 100644 index 00000000000..91657a30030 --- /dev/null +++ b/sale_confirm_group/models/sale_order.py @@ -0,0 +1,92 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import api, exceptions, fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + user_can_confirm = fields.Boolean( + compute="_compute_user_can_confirm", compute_sudo=True + ) + + @api.depends( + # Keep these dotted fields as dependencies, because: + # - chances of them being updated very often (leading to lots of records cache + # invalidations and field recomputation) are quite low + # - removing them in favor of ``company_id`` alone (or no deps at all) means + # we'd have to invalidate the records cache manually when we need to check + # whether a user can actually confirm a sale order (to avoid using an outdated + # value for this field, since settings may have been updated in the meanwhile) + "company_id.use_sale_confirmation_groups", + "company_id.sale_confirmation_group_ids.user_ids", + ) + @api.depends_context("uid") + def _compute_user_can_confirm(self): + user = self.env.user + for company, sales in self.grouped("company_id").items(): + if not company.use_sale_confirmation_groups: + sales.user_can_confirm = True + elif not (groups := company.sale_confirmation_group_ids): + sales.user_can_confirm = True + else: + sales.user_can_confirm = ( + user in groups.with_context(active_test=False).user_ids + ) + + def action_confirm(self): + # OVERRIDE: prevent unallowed users from confirming a sale order, + # and raise a ``ValidationError`` instead + + # 1- check skipped: exit early + if self._skip_check_user_can_confirm(): + return super().action_confirm() + + # 2- all SO in ``self`` can be confirmed + elif (can_confirm := self._filter_user_can_confirm()) == self: + return super().action_confirm() + + # 3- at least 1 SO cannot be confirmed by the user + raise exceptions.ValidationError( + self.env._( + "User %(user)s cannot confirm Sale(s) %(sales)s", + user=self.env.user.name, + sales=", ".join( + (self - can_confirm).mapped(lambda s: f"'{s.display_name}'") + ), + ) + ) + + def _skip_check_user_can_confirm(self) -> bool: + """Defines whether checks upon user permissions to confirm should be skipped + + Set context key "skip_check_user_can_confirm" as ``True`` to skip the checks. + """ + return bool(self.env.context.get("skip_check_user_can_confirm", self.env.su)) + + def _filter_user_can_confirm(self) -> "SaleOrder": + """Returns the subset of records that the current user can confirm + + Hook method, can be overridden + """ + return self.filtered("user_can_confirm") + + @api.model + def _get_view(self, view_id=None, view_type="form", **options): + # OVERRIDE: hide the SO ``action_confirm`` button to users who shouldn't be + # allowed confirmation + # NB: we override ``sale.order._get_view()``, not ``sale.order.get_view()``, + # because: + # - the result of ``sale.order._get_view()`` is cached + # - the result of ``sale.order._get_view()`` is updated by method + # ``ir.ui.view._add_missing_fields()`` to automatically add fields needed for + # the evaluation of nodes' attributes (required, invisible, etc...), + # so we don't need to do it here + arch, view = super()._get_view(view_id=view_id, view_type=view_type, **options) + for node in arch.xpath("//button[@name='action_confirm']"): + if value := node.get("invisible"): + node.set("invisible", f"not user_can_confirm or ({value})") + else: + node.set("invisible", "not user_can_confirm") + return arch, view diff --git a/sale_confirm_group/pyproject.toml b/sale_confirm_group/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/sale_confirm_group/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sale_confirm_group/readme/CONFIGURE.md b/sale_confirm_group/readme/CONFIGURE.md new file mode 100644 index 00000000000..017b815eabd --- /dev/null +++ b/sale_confirm_group/readme/CONFIGURE.md @@ -0,0 +1,7 @@ +* go to Sales / Configuration / Settings +* scroll until you find the "Use SO Confirmation Groups" checkbox +* if you want to restrict SO confirmation permission: + * activate the checkbox + * add at least 1 security group to the list below the checkbox +* if you don't want to restrict SO confirmation permission: + * deactivate the checkbox, or remove all security groups from the list below the checkbox diff --git a/sale_confirm_group/readme/CONTRIBUTORS.md b/sale_confirm_group/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..8bebebda9c9 --- /dev/null +++ b/sale_confirm_group/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Silvio Gregorini \ +- Simone Orsi \ +- Joshua Jan \ \ No newline at end of file diff --git a/sale_confirm_group/readme/DESCRIPTION.md b/sale_confirm_group/readme/DESCRIPTION.md new file mode 100644 index 00000000000..7bf212b0ae2 --- /dev/null +++ b/sale_confirm_group/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module allows configuring a list of groups per-company who are granted permission to confirm sale orders: + +1. button "Confirm" in sale views is always hidden for users not in those groups +2. if users outside those groups try to confirm a SO, an error is raised diff --git a/sale_confirm_group/static/description/icon.png b/sale_confirm_group/static/description/icon.png new file mode 100644 index 00000000000..1dcc49c24f3 Binary files /dev/null and b/sale_confirm_group/static/description/icon.png differ diff --git a/sale_confirm_group/static/description/index.html b/sale_confirm_group/static/description/index.html new file mode 100644 index 00000000000..ddf542ada51 --- /dev/null +++ b/sale_confirm_group/static/description/index.html @@ -0,0 +1,455 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Sale Confirmation Group

+ +

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

+

This module allows configuring a list of groups per-company who are +granted permission to confirm sale orders:

+
    +
  1. button “Confirm” in sale views is always hidden for users not in +those groups
  2. +
  3. if users outside those groups try to confirm a SO, an error is raised
  4. +
+

Table of contents

+ +
+

Configuration

+
    +
  • go to Sales / Configuration / Settings
  • +
  • scroll until you find the “Use SO Confirmation Groups” checkbox
  • +
  • if you want to restrict SO confirmation permission:
      +
    • activate the checkbox
    • +
    • add at least 1 security group to the list below the checkbox
    • +
    +
  • +
  • if you don’t want to restrict SO confirmation permission:
      +
    • deactivate the checkbox, or remove all security groups from the list +below the checkbox
    • +
    +
  • +
+
+
+

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/sale-workflow project on GitHub.

+

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

+
+
+
+
+ + diff --git a/sale_confirm_group/tests/__init__.py b/sale_confirm_group/tests/__init__.py new file mode 100644 index 00000000000..1849033b133 --- /dev/null +++ b/sale_confirm_group/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_confirm_group diff --git a/sale_confirm_group/tests/common.py b/sale_confirm_group/tests/common.py new file mode 100644 index 00000000000..59332752af6 --- /dev/null +++ b/sale_confirm_group/tests/common.py @@ -0,0 +1,33 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.tests.common import new_test_user + +from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT +from odoo.addons.sale.tests.common import TestSaleCommon + + +class TestSaleConfirmGroupCommon(TestSaleCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Update context env + cls.env = cls.env(context=dict(cls.env.context, **DISABLED_MAIL_CONTEXT)) + + # Prepare groups + cls.group_sale_user_xmlid = "sales_team.group_sale_salesman" + cls.group_sale_user = cls.env.ref(cls.group_sale_user_xmlid) + cls.group_sale_admin_xmlid = "sales_team.group_sale_manager" + cls.group_sale_admin = cls.env.ref(cls.group_sale_admin_xmlid) + + # Prepare a test user with "Sales / User: Own Documents Only" group + cls.test_user = new_test_user( + cls.env, + login="test-sale-confirm-group-user", + groups=cls.group_sale_user_xmlid, + ) + + # Setup company: activate the feature, add "Sales / Administrator" as the only + # group allowed to confirm sales + cls.env.company.use_sale_confirmation_groups = True + cls.env.company.sale_confirmation_group_ids = cls.group_sale_admin diff --git a/sale_confirm_group/tests/test_sale_confirm_group.py b/sale_confirm_group/tests/test_sale_confirm_group.py new file mode 100644 index 00000000000..9ac065ae138 --- /dev/null +++ b/sale_confirm_group/tests/test_sale_confirm_group.py @@ -0,0 +1,139 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from lxml.etree import fromstring + +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.tests.common import users + +from .common import TestSaleConfirmGroupCommon + + +class TestSaleConfirmGroup(TestSaleConfirmGroupCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Dummy customer for SO creation + cls.customer = cls.env["res.partner"].create({"name": "Customer"}) + cls.product = cls.env["product.product"].create( + { + "name": "Test Product", + "type": "consu", + "sale_ok": True, + "standard_price": 50, + } + ) + + def _create_sale(self): + return self.env["sale.order"].create( + { + "partner_id": self.customer.id, + "order_line": [ + fields.Command.create( + { + "name": "Product", + "product_id": self.product.id, + "product_uom_qty": 1, + "price_unit": 50.00, + } + ) + ], + } + ) + + @users("test-sale-confirm-group-user") + def test_00_groups_usage_not_active(self): + # Deactivate the usage of confirmation groups: the current user can confirm + sale = self._create_sale() + self.env.company.sudo().write( + { + "use_sale_confirmation_groups": False, + "sale_confirmation_group_ids": [fields.Command.clear()], + } + ) + self.assertTrue(sale.user_can_confirm) + sale.action_confirm() + self.assertEqual(sale.state, "sale") + + @users("test-sale-confirm-group-user") + def test_01_groups_usage_active_no_groups(self): + # Activate the usage of confirmation groups, but remove all groups: the current + # user can confirm + sale = self._create_sale() + self.env.company.sudo().write( + { + "use_sale_confirmation_groups": True, + "sale_confirmation_group_ids": [fields.Command.clear()], + } + ) + self.assertTrue(sale.user_can_confirm) + sale.action_confirm() + self.assertEqual(sale.state, "sale") + + @users("test-sale-confirm-group-user") + def test_02_groups_usage_active_with_groups(self): + # Keep the usage of confirmation groups, keep the sale admins as the only + # allowed group: the current user cannot confirm + sale = self._create_sale() + self.assertFalse(sale.user_can_confirm) + with self.assertRaises(ValidationError) as error: + sale.action_confirm() + self.assertEqual( + error.exception.args[0], + f"User {self.env.user.name} cannot confirm Sale(s) '{sale.display_name}'", + ) + # Add the sale users as allowed group: the current user can now confirm + self.env.company.sudo().sale_confirmation_group_ids += self.group_sale_user + self.assertTrue(sale.user_can_confirm) + sale.action_confirm() + self.assertEqual(sale.state, "sale") + # Remove the sale users as allowed group: the current user cannot confirm + sale = self._create_sale() + self.env.company.sudo().sale_confirmation_group_ids -= self.group_sale_user + self.assertFalse(sale.user_can_confirm) + with self.assertRaises(ValidationError) as error: + sale.action_confirm() + self.assertEqual( + error.exception.args[0], + f"User {self.env.user.name} cannot confirm Sale(s) '{sale.display_name}'", + ) + # Add the current user to the "Sales / Administrator" group: the current user + # can now confirm + self.test_user.group_ids += self.group_sale_admin + self.assertTrue(sale.user_can_confirm) + sale.action_confirm() + self.assertEqual(sale.state, "sale") + # Remove the current user from the "Sales / Administrator" group: the current + # user cannot confirm + sale = self._create_sale() + self.test_user.group_ids -= self.group_sale_admin + self.assertFalse(sale.user_can_confirm) + with self.assertRaises(ValidationError) as error: + sale.action_confirm() + self.assertEqual( + error.exception.args[0], + f"User {self.env.user.name} cannot confirm Sale(s) '{sale.display_name}'", + ) + + @users("test-sale-confirm-group-user") + def test_03_context_key(self): + # Keep the usage of confirmation groups, keep the sale admins as the only + # allowed group: the current user cannot confirm + sale = self._create_sale() + self.assertFalse(sale.user_can_confirm) + with self.assertRaises(ValidationError) as error: + sale.action_confirm() + self.assertEqual( + error.exception.args[0], + f"User {self.env.user.name} cannot confirm Sale(s) '{sale.display_name}'", + ) + # Add the "skip_check_user_can_confirm" context key to skip user permission + # checks: the curren user can confirm + sale.with_context(skip_check_user_can_confirm=True).action_confirm() + self.assertEqual(sale.state, "sale") + + def test_action_confirm_invisible(self): + arch = fromstring(self.env["sale.order"].get_view(view_type="form")["arch"]) + for node in arch.xpath("//button[@name='action_confirm']"): + self.assertIn("not user_can_confirm", node.get("invisible")) diff --git a/sale_confirm_group/wizards/__init__.py b/sale_confirm_group/wizards/__init__.py new file mode 100644 index 00000000000..0deb68c4680 --- /dev/null +++ b/sale_confirm_group/wizards/__init__.py @@ -0,0 +1 @@ +from . import res_config_settings diff --git a/sale_confirm_group/wizards/res_config_settings.py b/sale_confirm_group/wizards/res_config_settings.py new file mode 100644 index 00000000000..0c227cf3a25 --- /dev/null +++ b/sale_confirm_group/wizards/res_config_settings.py @@ -0,0 +1,18 @@ +# Copyright 2025 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class Settings(models.TransientModel): + _inherit = "res.config.settings" + + use_sale_confirmation_groups = fields.Boolean( + related="company_id.use_sale_confirmation_groups", + readonly=False, + ) + sale_confirmation_group_ids = fields.Many2many( + "res.groups", + related="company_id.sale_confirmation_group_ids", + readonly=False, + ) diff --git a/sale_confirm_group/wizards/res_config_settings.xml b/sale_confirm_group/wizards/res_config_settings.xml new file mode 100644 index 00000000000..ce4aa8d93ad --- /dev/null +++ b/sale_confirm_group/wizards/res_config_settings.xml @@ -0,0 +1,30 @@ + + + + res.config.settings + + + + + + + + + + + + + + +