diff --git a/setup/stock_inventory_security/odoo/addons/stock_inventory_security b/setup/stock_inventory_security/odoo/addons/stock_inventory_security new file mode 120000 index 000000000000..f9e25cd7746f --- /dev/null +++ b/setup/stock_inventory_security/odoo/addons/stock_inventory_security @@ -0,0 +1 @@ +../../../../stock_inventory_security \ No newline at end of file diff --git a/setup/stock_inventory_security/setup.py b/setup/stock_inventory_security/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_inventory_security/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_inventory_discrepancy/__init__.py b/stock_inventory_discrepancy/__init__.py index 0ceaa3daff2a..e1e144406194 100644 --- a/stock_inventory_discrepancy/__init__.py +++ b/stock_inventory_discrepancy/__init__.py @@ -2,4 +2,3 @@ from . import models from . import wizards -from .hooks import post_load_hook diff --git a/stock_inventory_discrepancy/__manifest__.py b/stock_inventory_discrepancy/__manifest__.py index c3fa9c8c26bf..b1d6eef925cf 100644 --- a/stock_inventory_discrepancy/__manifest__.py +++ b/stock_inventory_discrepancy/__manifest__.py @@ -20,7 +20,6 @@ "wizards/confirm_discrepancy_wiz.xml", ], "license": "AGPL-3", - "post_load": "post_load_hook", "installable": True, "application": False, } diff --git a/stock_inventory_discrepancy/hooks.py b/stock_inventory_discrepancy/hooks.py deleted file mode 100644 index 84a1de372699..000000000000 --- a/stock_inventory_discrepancy/hooks.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright 2019 ForgeFlow S.L. -# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). - - -from odoo import _, fields -from odoo.exceptions import UserError -from odoo.tools.float_utils import float_compare - -from odoo.addons.stock.models.stock_quant import StockQuant - - -def post_load_hook(): - def _apply_inventory_discrepancy(self): - """Override method to avoid inline group validation""" - move_vals = [] - # START HOOK: - Allow specific group to validate inventory - # - Allow validate on pending status - if ( - not self.user_has_groups("stock.group_stock_manager") - and not self.user_has_groups( - "stock_inventory_discrepancy.group_stock_inventory_validation" - ) - and not self.user_has_groups( - "stock_inventory_discrepancy.group_stock_inventory_validation_always" - ) - ): - raise UserError( - _("Only a stock manager can validate an inventory adjustment.") - ) - # Allow to write last_inventory_date on stock.location - self = self.sudo() - # END HOOK - for quant in self: - # Create and validate a move so that the quant matches its `inventory_quantity`. - if ( - float_compare( - quant.inventory_diff_quantity, - 0, - precision_rounding=quant.product_uom_id.rounding, - ) - > 0 - ): - move_vals.append( - quant._get_inventory_move_values( - quant.inventory_diff_quantity, - quant.product_id.with_company( - quant.company_id - ).property_stock_inventory, - quant.location_id, - ) - ) - else: - move_vals.append( - quant._get_inventory_move_values( - -quant.inventory_diff_quantity, - quant.location_id, - quant.product_id.with_company( - quant.company_id - ).property_stock_inventory, - out=True, - ) - ) - moves = ( - self.env["stock.move"].with_context(inventory_mode=False).create(move_vals) - ) - moves._action_done() - self.location_id.write({"last_inventory_date": fields.Date.today()}) - date_by_location = { - loc: loc._get_next_inventory_date() for loc in self.mapped("location_id") - } - for quant in self: - quant.inventory_date = date_by_location[quant.location_id] - self.write({"inventory_quantity": 0, "user_id": False}) - self.write({"inventory_diff_quantity": 0}) - - StockQuant._patch_method("_apply_inventory", _apply_inventory_discrepancy) diff --git a/stock_inventory_discrepancy/models/stock_location.py b/stock_inventory_discrepancy/models/stock_location.py index 96f927673d93..179c38939303 100644 --- a/stock_inventory_discrepancy/models/stock_location.py +++ b/stock_inventory_discrepancy/models/stock_location.py @@ -21,6 +21,17 @@ class StockLocation(models.Model): ) def write(self, values): + # OVERRIDE: Allow the inventory user to set the last inventory date. + # https://github.com/odoo/odoo/blob/534220ee/addons/stock/models/stock_quant.py#L775 + if ( + self.env.context.get("_stock_inventory_discrepancy") + and len(values) == 1 + and "last_inventory_date" in values + and self.user_has_groups( + "stock_inventory_discrepancy.group_stock_inventory_validation" + ) + ): + self = self.sudo() res = super().write(values) # Set the discrepancy threshold for all child locations if values.get("discrepancy_threshold", False): diff --git a/stock_inventory_discrepancy/models/stock_quant.py b/stock_inventory_discrepancy/models/stock_quant.py index 2674848b3e1d..1f8316f37254 100644 --- a/stock_inventory_discrepancy/models/stock_quant.py +++ b/stock_inventory_discrepancy/models/stock_quant.py @@ -73,6 +73,23 @@ def _compute_has_over_discrepancy(self): rec.discrepancy_percent > rec.discrepancy_threshold ) + def user_has_groups(self, groups): + # Most inventory adjustment operations are limited to users having + # the Inventory Manager group. + # OVERRIDE: Hijack the check to replace it with our own group. + if groups == "stock.group_stock_manager" and self.env.context.get( + "_stock_inventory_discrepancy" + ): + groups = "stock_inventory_discrepancy.group_stock_inventory_validation" + return super().user_has_groups(groups) + + def _apply_inventory(self): + if self.user_has_groups( + "stock_inventory_discrepancy.group_stock_inventory_validation" + ): + self = self.with_context(_stock_inventory_discrepancy=True) + return super()._apply_inventory() + def action_apply_inventory(self): if self.env.context.get("skip_exceeded_discrepancy", False): return super().action_apply_inventory() diff --git a/stock_inventory_security/README.rst b/stock_inventory_security/README.rst new file mode 100644 index 000000000000..c26961f4e147 --- /dev/null +++ b/stock_inventory_security/README.rst @@ -0,0 +1 @@ +# To be generated by bot diff --git a/stock_inventory_security/__init__.py b/stock_inventory_security/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/stock_inventory_security/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_inventory_security/__manifest__.py b/stock_inventory_security/__manifest__.py new file mode 100644 index 000000000000..6f1e552ede74 --- /dev/null +++ b/stock_inventory_security/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2024 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Stock Quant Inventory Security", + "summary": "Dedicated security group to apply inventory adjustments", + "version": "16.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["ivantodorovich"], + "website": "https://github.com/OCA/stock-logistics-warehouse", + "license": "AGPL-3", + "category": "Inventory", + "depends": ["stock"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "views/product.xml", + "views/stock_quant.xml", + ], + "demo": [ + "demo/demo.xml", + ], +} diff --git a/stock_inventory_security/demo/demo.xml b/stock_inventory_security/demo/demo.xml new file mode 100644 index 000000000000..c5dd2f94d928 --- /dev/null +++ b/stock_inventory_security/demo/demo.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/stock_inventory_security/models/__init__.py b/stock_inventory_security/models/__init__.py new file mode 100644 index 000000000000..d979564a36df --- /dev/null +++ b/stock_inventory_security/models/__init__.py @@ -0,0 +1,4 @@ +from . import product_product +from . import stock_location +from . import stock_lot +from . import stock_quant diff --git a/stock_inventory_security/models/product_product.py b/stock_inventory_security/models/product_product.py new file mode 100644 index 000000000000..390465aaa8d1 --- /dev/null +++ b/stock_inventory_security/models/product_product.py @@ -0,0 +1,23 @@ +# Copyright 2024 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def user_has_groups(self, groups): + # Most inventory adjustment operations are limited to users having + # the Inventory Manager group. + # OVERRIDE: Hijack the check to replace it with our own group. + if groups == "stock.group_stock_manager" and self.env.context.get( + "_stock_inventory_security" + ): + groups = "stock_inventory_security.group_inventory_adjustment" + return super().user_has_groups(groups) + + def action_open_quants(self): + if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"): + self = self.with_context(_stock_inventory_security=True) + return super().action_open_quants() diff --git a/stock_inventory_security/models/stock_location.py b/stock_inventory_security/models/stock_location.py new file mode 100644 index 000000000000..1ff8deef8e11 --- /dev/null +++ b/stock_inventory_security/models/stock_location.py @@ -0,0 +1,22 @@ +# Copyright 2024 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockLocation(models.Model): + _inherit = "stock.location" + + def write(self, vals): + # OVERRIDE: Allow the inventory user to set the last inventory date. + # https://github.com/odoo/odoo/blob/534220ee/addons/stock/models/stock_quant.py#L775 + if ( + self.env.context.get("_stock_inventory_security") + and len(vals) == 1 + and "last_inventory_date" in vals + and self.user_has_groups( + "stock_inventory_security.group_inventory_adjustment" + ) + ): + self = self.sudo() + return super().write(vals) diff --git a/stock_inventory_security/models/stock_lot.py b/stock_inventory_security/models/stock_lot.py new file mode 100644 index 000000000000..75812fecfb2a --- /dev/null +++ b/stock_inventory_security/models/stock_lot.py @@ -0,0 +1,24 @@ +# Copyright 2024 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockLot(models.Model): + _inherit = "stock.lot" + + def user_has_groups(self, groups): + # Most inventory adjustment operations are limited to users having + # the Inventory Manager group. + # OVERRIDE: Hijack the check to replace it with our own group.) + if groups == "stock.group_stock_manager" and self.env.context.get( + "_stock_inventory_security" + ): + groups = "stock_inventory_security.group_inventory_adjustment" + return super().user_has_groups(groups) + + def action_lot_open_quants(self): + # OVERRIDE: Add the inventory_mode context + if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"): + self = self.with_context(_stock_inventory_security=True) + return super().action_lot_open_quants() diff --git a/stock_inventory_security/models/stock_quant.py b/stock_inventory_security/models/stock_quant.py new file mode 100644 index 000000000000..452c13fe3417 --- /dev/null +++ b/stock_inventory_security/models/stock_quant.py @@ -0,0 +1,43 @@ +# Copyright 2024 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + inventory_quantity_auto_apply = fields.Float( + # Change stock.group_stock_manager to our own group. + groups="stock_inventory_security.group_inventory_adjustment", + ) + + def user_has_groups(self, groups): + # Most inventory adjustment operations are limited to users having + # the Inventory Manager group. + # OVERRIDE: Hijack the check to replace it with our own group. + if groups == "stock.group_stock_manager" and self.env.context.get( + "_stock_inventory_security" + ): + groups = "stock_inventory_security.group_inventory_adjustment" + return super().user_has_groups(groups) + + def _get_quants_action(self, domain=None, extend=False): + # OVERRIDE: Show the editable quants view for users having the Inventory + # Adjustments group. + # The original method would only do it for Stock Managers. + if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"): + self = self.with_context(_stock_inventory_security=True) + return super()._get_quants_action(domain=domain, extend=extend) + + def action_view_inventory(self): + # OVERRIDE: Disable the "My count" filter for users having the Inventory + # Adjustments group. + if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"): + self = self.with_context(_stock_inventory_security=True) + return super().action_view_inventory() + + def _apply_inventory(self): + if self.user_has_groups("stock_inventory_security.group_inventory_adjustment"): + self = self.with_context(_stock_inventory_security=True) + return super()._apply_inventory() diff --git a/stock_inventory_security/readme/CONTRIBUTORS.md b/stock_inventory_security/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..7992345c3cce --- /dev/null +++ b/stock_inventory_security/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* Iván Todorovich diff --git a/stock_inventory_security/readme/README.md b/stock_inventory_security/readme/README.md new file mode 100644 index 000000000000..c60cf67ac8f6 --- /dev/null +++ b/stock_inventory_security/readme/README.md @@ -0,0 +1,4 @@ +In standard, the inventory adjustments can only be applied by **Inventory / Manager** users. + +This module introduces a new security group named **Stock: Inventory Adjustments**, which +grants regular stock users the ability to apply inventory adjustments. diff --git a/stock_inventory_security/security/ir.model.access.csv b/stock_inventory_security/security/ir.model.access.csv new file mode 100644 index 000000000000..cafc1a2b43ae --- /dev/null +++ b/stock_inventory_security/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_inventory_adjustment_name,stock.inventory.adjustment.name,stock.model_stock_inventory_adjustment_name,group_inventory_adjustment,1,1,1,0 +access_stock_request_count,stock.request.count,stock.model_stock_request_count,group_inventory_adjustment,1,1,1,0 diff --git a/stock_inventory_security/security/security.xml b/stock_inventory_security/security/security.xml new file mode 100644 index 000000000000..6f28befc83ea --- /dev/null +++ b/stock_inventory_security/security/security.xml @@ -0,0 +1,19 @@ + + + + + Stock: Inventory Adjustments + + + + + + + + diff --git a/stock_inventory_security/tests/__init__.py b/stock_inventory_security/tests/__init__.py new file mode 100644 index 000000000000..acaff09facdd --- /dev/null +++ b/stock_inventory_security/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_inventory_security diff --git a/stock_inventory_security/tests/test_stock_inventory_security.py b/stock_inventory_security/tests/test_stock_inventory_security.py new file mode 100644 index 000000000000..76e78153a8d9 --- /dev/null +++ b/stock_inventory_security/tests/test_stock_inventory_security.py @@ -0,0 +1,156 @@ +# Copyright 2024 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError +from odoo.tests import Form, TransactionCase, new_test_user, users + +from odoo.addons.base.tests.common import DISABLED_MAIL_CONTEXT + + +class TestStockInventorySecurity(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, **DISABLED_MAIL_CONTEXT)) + # Lazy tests compatibility with `stock_inventory_discrepancy` + cls.env = cls.env(context=dict(cls.env.context, skip_exceeded_discrepancy=True)) + # Create test records + cls.inventory_user = new_test_user( + cls.env, + login="inventory", + groups="stock_inventory_security.group_inventory_adjustment", + ) + cls.stock_user = new_test_user( + cls.env, + login="stock", + groups="stock.group_stock_user", + ) + cls.stock_location = cls.env.ref("stock.stock_location_stock") + cls.product = cls.env["product.product"].create( + { + "name": "Test product", + "type": "product", + } + ) + cls.product_lot = cls.env["stock.lot"].create( + { + "product_id": cls.product.id, + "company_id": cls.env.company.id, + } + ) + cls.product_quants = ( + cls.env["stock.quant"] + .with_context(inventory_mode=True) + .create( + { + "product_id": cls.product.id, + "inventory_quantity": 4, + "lot_id": cls.product_lot.id, + "location_id": cls.stock_location.id, + } + ) + ) + cls.product_quants.action_apply_inventory() + + @users("inventory", "admin") + def test_inventory_user_product_action_open_quants(self): + """Test that the inventory user gets into inventory mode from products""" + res = self.product.with_user(self.env.user).action_open_quants() + self.assertFalse(res["context"].get("search_default_my_count")) + + @users("stock") + def test_stock_user_product_action_open_quants(self): + """Test that the stock user does not get into inventory mode from products""" + res = self.product.with_user(self.env.user).action_open_quants() + self.assertTrue(res["context"].get("search_default_my_count")) + + @users("inventory", "admin") + def test_inventory_user_quant_action(self): + """Test that the inventory user gets into inventory mode from quants""" + res = self.product_quants.with_user(self.env.user).action_view_inventory() + self.assertFalse(res["context"].get("search_default_my_count")) + + @users("stock") + def test_stock_user_quant_action(self): + """Test that the stock user does not get into inventory mode from quants""" + res = self.product_quants.with_user(self.env.user).action_view_inventory() + self.assertTrue(res["context"].get("search_default_my_count")) + + @users("inventory", "admin") + def test_inventory_user_lot_action(self): + """Test that the inventory user gets into inventory mode from lots""" + res = self.product_lot.with_user(self.env.user).action_lot_open_quants() + self.assertEqual( + res["view_id"], self.env.ref("stock.view_stock_quant_tree_editable").id + ) + self.assertTrue(res["context"].get("inventory_mode")) + + @users("stock") + def test_stock_user_lot_action(self): + """Test that the stock user does not get into inventory mode from lots""" + res = self.product_lot.with_user(self.stock_user).action_lot_open_quants() + self.assertEqual(res["view_id"], self.env.ref("stock.view_stock_quant_tree").id) + self.assertFalse(res["context"].get("inventory_mode")) + + @users("inventory", "admin") + def test_inventory_user_apply_inventory(self): + """Test that the inventory user can apply inventory""" + quant = ( + self.env["stock.quant"] + .with_context(inventory_mode=True) + .create( + { + "product_id": self.product.id, + "inventory_quantity": 10, + "lot_id": self.product_lot.id, + "location_id": self.stock_location.id, + } + ) + ) + quant.action_apply_inventory() + self.assertEqual(self.product.qty_available, 10) + + @users("stock") + def test_stock_user_apply_inventory(self): + """Test that the stock user cannot apply inventory""" + with self.assertRaisesRegex( + UserError, "Only a stock manager can validate an inventory adjustment." + ): + quant = self.env["stock.quant"].create( + { + "product_id": self.product.id, + "inventory_quantity": 10, + "lot_id": self.product_lot.id, + "location_id": self.stock_location.id, + } + ) + quant.action_apply_inventory() + + @users("inventory", "admin") + def test_inventory_user_apply_inventory_reason(self): + """Test that the inventory user can apply inventory with a reason""" + quant = ( + self.env["stock.quant"] + .with_context(inventory_mode=True) + .create( + { + "product_id": self.product.id, + "lot_id": self.product_lot.id, + "location_id": self.stock_location.id, + "inventory_quantity": 10, + } + ) + ) + form_wizard = Form( + self.env["stock.inventory.adjustment.name"].with_context( + default_quant_ids=quant.ids + ) + ) + form_wizard.inventory_adjustment_name = "Inventory Adjustment - Test" + form_wizard.save().action_apply() + self.assertTrue( + self.env["stock.move"].search( + [("reference", "=", "Inventory Adjustment - Test")], limit=1 + ) + ) + self.assertEqual(self.product.qty_available, 10) diff --git a/stock_inventory_security/views/product.xml b/stock_inventory_security/views/product.xml new file mode 100644 index 000000000000..455a26c50e87 --- /dev/null +++ b/stock_inventory_security/views/product.xml @@ -0,0 +1,59 @@ + + + + + + product.product + + + + + + + + product.product + + + + + + + + product.template + + + + + + + diff --git a/stock_inventory_security/views/stock_quant.xml b/stock_inventory_security/views/stock_quant.xml new file mode 100644 index 000000000000..92fb1562f7d4 --- /dev/null +++ b/stock_inventory_security/views/stock_quant.xml @@ -0,0 +1,34 @@ + + + + + + stock.quant + + + + + + + +