diff --git a/stock_inventory_adjustment_security/README.md b/stock_inventory_adjustment_security/README.md new file mode 100644 index 0000000..793127c --- /dev/null +++ b/stock_inventory_adjustment_security/README.md @@ -0,0 +1,91 @@ +# Stock Inventory Adjustment Security + +## Overview + +This module adds granular security controls for inventory adjustments in Odoo. It allows you to separate the ability to **count inventory** from the ability to **apply adjustments**. + +## Features + +- **New Security Group**: "Inventory: Manual Adjustments" +- **Separation of Duties**: + - Regular inventory users can view stock and enter counted quantities + - Only privileged users can apply the adjustments to modify actual stock levels +- **No Impact on Automatic Operations**: Stock moves from pickings, manufacturing orders, and other automatic operations work normally + +## Use Case + +Perfect for organizations that want to: +- Allow warehouse staff to participate in inventory counts +- Restrict who can actually modify stock levels +- Maintain audit trails by limiting adjustment privileges +- Implement proper inventory control procedures + +## How It Works + +### Technical Implementation + +The module overrides the `write()` method on `stock.quant` to check: +1. If the operation is in "inventory mode" (manual adjustment context) +2. If the `quantity` field is being modified +3. If the user has the required security group + +The check uses Odoo's built-in `_is_inventory_mode()` method which returns `True` only when: +- The `inventory_mode` context flag is set (manual adjustments) +- The user has the `stock.group_stock_user` group + +### User Experience + +**Regular Inventory User:** +- Can navigate to Inventory > Inventory Adjustments +- Can view current stock quantities +- Can enter counted quantities in the `inventory_quantity` field +- **Cannot** click "Apply" to commit the changes +- Receives clear error message when attempting to apply + +**Privileged User (with Manual Adjustments group):** +- Can do everything a regular user can do +- **Can** click "Apply" to commit inventory adjustments +- Can modify actual stock quantities + +## Configuration + +1. Install the module +2. Go to Settings > Users & Companies > Users +3. Edit a user who should be able to apply inventory adjustments +4. Add them to the "Inventory: Manual Adjustments" group + +## Testing + +The module includes comprehensive test coverage: + +```bash +# Run all tests +odoo-bin -d your_database -i stock_inventory_adjustment_security --test-enable --stop-after-init + +# Run specific test class +odoo-bin -d your_database --test-tags stock_inventory_adjustment_security +``` + +### Test Cases + +- ✅ Regular users can set inventory_quantity (count) +- ✅ Regular users cannot apply adjustments +- ✅ Privileged users can apply adjustments +- ✅ Automatic operations (pickings, etc.) are not affected +- ✅ Inventory mode detection works correctly +- ✅ Multiple quant adjustments work correctly +- ✅ Non-inventory mode writes are not restricted + +## Compatibility + +- Odoo 18.0 +- Depends on: `stock` + +## License + +LGPL-3 + +## Author + +Bemade Inc. +https://bemade.org diff --git a/stock_inventory_adjustment_security/__init__.py b/stock_inventory_adjustment_security/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/stock_inventory_adjustment_security/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_inventory_adjustment_security/__manifest__.py b/stock_inventory_adjustment_security/__manifest__.py new file mode 100644 index 0000000..df4a8a8 --- /dev/null +++ b/stock_inventory_adjustment_security/__manifest__.py @@ -0,0 +1,30 @@ +{ + "name": "Stock Inventory Adjustment Security", + "author": "Bemade Inc.", + "version": "18.0.1.0.0", + "category": "Inventory/Inventory", + "summary": "Restrict manual inventory adjustments to privileged users", + "description": """ +Stock Inventory Adjustment Security +==================================== + +This module adds an additional security layer for inventory adjustments: + +* Creates a new security group "Inventory: Manual Adjustments" +* Regular inventory users can view and count inventory (set inventory_quantity) +* Only users in the privileged group can apply adjustments (modify actual quantity) +* Automatic stock operations (pickings, manufacturing, etc.) are not affected + +This allows inventory staff to participate in counts while restricting who can +actually apply the adjustments and modify stock levels. + """, + "website": "https://www.bemade.org", + "depends": ["stock"], + "data": [ + "security/security.xml", + ], + "installable": True, + "auto_install": False, + "license": "LGPL-3", + "application": False, +} diff --git a/stock_inventory_adjustment_security/models/__init__.py b/stock_inventory_adjustment_security/models/__init__.py new file mode 100644 index 0000000..70f8e6c --- /dev/null +++ b/stock_inventory_adjustment_security/models/__init__.py @@ -0,0 +1 @@ +from . import stock_quant diff --git a/stock_inventory_adjustment_security/models/stock_quant.py b/stock_inventory_adjustment_security/models/stock_quant.py new file mode 100644 index 0000000..e714f5d --- /dev/null +++ b/stock_inventory_adjustment_security/models/stock_quant.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +from odoo import models, _, api +from odoo.exceptions import AccessError + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + def _check_inventory_adjustment_permission(self): + """ + Check if the current user has permission to make inventory adjustments. + + Raises AccessError if the user lacks the required group. + """ + if not self.env.user.has_group( + "stock_inventory_adjustment_security.group_inventory_manual_adjustments" + ): + raise AccessError( + _( + "You don't have permission to apply inventory adjustments. " + "You can count inventory, but only authorized users can apply the changes. " + "Please contact your inventory manager." + ) + ) + + def action_apply_inventory(self): + """ + Override action_apply_inventory to restrict who can apply adjustments. + + This is the main entry point for applying manual inventory adjustments. + Regular users can count, but only privileged users can apply. + """ + self._check_inventory_adjustment_permission() + return super().action_apply_inventory() + + @api.model_create_multi + def create(self, vals_list): + """ + Override create to restrict quant creation in inventory mode. + + Regular users should not be able to create new quants in inventory mode + as this is another way to manipulate inventory levels. + """ + if self._is_inventory_mode(): + self._check_inventory_adjustment_permission() + + return super().create(vals_list) + + def write(self, vals): + """ + Override write to restrict quantity changes in inventory mode. + + This catches direct quantity modifications during inventory adjustments. + Automatic operations (pickings, manufacturing, etc.) don't set + inventory_mode context, so they are unaffected. + """ + if self._is_inventory_mode() and "quantity" in vals: + self._check_inventory_adjustment_permission() + + return super().write(vals) diff --git a/stock_inventory_adjustment_security/security/security.xml b/stock_inventory_adjustment_security/security/security.xml new file mode 100644 index 0000000..cc25312 --- /dev/null +++ b/stock_inventory_adjustment_security/security/security.xml @@ -0,0 +1,12 @@ + + + + Manual Adjustments + + + + Users in this group can apply manual inventory adjustments. + Regular inventory users can count inventory but cannot apply the adjustments. + + + diff --git a/stock_inventory_adjustment_security/tests/__init__.py b/stock_inventory_adjustment_security/tests/__init__.py new file mode 100644 index 0000000..344fe71 --- /dev/null +++ b/stock_inventory_adjustment_security/tests/__init__.py @@ -0,0 +1 @@ +from . import test_inventory_adjustment_security diff --git a/stock_inventory_adjustment_security/tests/test_inventory_adjustment_security.py b/stock_inventory_adjustment_security/tests/test_inventory_adjustment_security.py new file mode 100644 index 0000000..d521bdb --- /dev/null +++ b/stock_inventory_adjustment_security/tests/test_inventory_adjustment_security.py @@ -0,0 +1,326 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import AccessError + + +class TestInventoryAdjustmentSecurity(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create test product + cls.product = cls.env["product.product"].create( + { + "name": "Test Product", + "type": "consu", + "is_storable": True, + } + ) + + # Get stock location + cls.stock_location = cls.env.ref("stock.warehouse0").lot_stock_id + + # Create initial quant with some quantity + cls.quant = ( + cls.env["stock.quant"] + .sudo() + .create( + { + "product_id": cls.product.id, + "location_id": cls.stock_location.id, + "quantity": 100.0, + } + ) + ) + + # Create test users + # Regular inventory user (can count but not apply) + cls.inventory_user = cls.env["res.users"].create( + { + "name": "Inventory Counter", + "login": "inventory_counter", + "email": "counter@test.com", + "groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])], + } + ) + + # Privileged user (can count and apply) + cls.privileged_user = cls.env["res.users"].create( + { + "name": "Inventory Manager", + "login": "inventory_manager", + "email": "manager@test.com", + "groups_id": [ + ( + 6, + 0, + [ + cls.env.ref("stock.group_stock_user").id, + cls.env.ref( + "stock_inventory_adjustment_security.group_inventory_manual_adjustments" + ).id, + ], + ) + ], + } + ) + + def test_regular_user_can_set_inventory_quantity(self): + """Test that regular users can set inventory_quantity (count)""" + quant = self.quant.with_user(self.inventory_user).with_context( + inventory_mode=True + ) + + # Should be able to set inventory_quantity + quant.write({"inventory_quantity": 90.0}) + self.assertEqual(quant.inventory_quantity, 90.0) + self.assertEqual(quant.inventory_diff_quantity, -10.0) + + def test_regular_user_cannot_apply_adjustment(self): + """Test that regular users cannot apply adjustments (modify quantity)""" + quant = self.quant.with_user(self.inventory_user).with_context(inventory_mode=True) + + # Set inventory quantity first + quant.write({"inventory_quantity": 90.0}) + + # Trying to apply (which writes to quantity field) should fail + with self.assertRaises(AccessError): + quant.write({"quantity": 90.0}) + + def test_regular_user_cannot_create_quants_in_inventory_mode(self): + """Test that regular users cannot create new quants in inventory mode""" + # Create a new product + new_product = self.env["product.product"].create( + { + "name": "New Product", + "type": "consu", + "is_storable": True, + } + ) + + # Trying to create a quant in inventory mode should fail + with self.assertRaises(AccessError): + self.env["stock.quant"].with_user(self.inventory_user).with_context( + inventory_mode=True + ).create( + { + "product_id": new_product.id, + "location_id": self.stock_location.id, + "quantity": 50.0, + } + ) + + def test_regular_user_cannot_call_apply_inventory(self): + """Test that regular users cannot call action_apply_inventory""" + quant = self.quant.with_user(self.inventory_user).with_context( + inventory_mode=True + ) + + # Set inventory quantity + quant.write({"inventory_quantity": 90.0}) + + # Trying to apply inventory should fail + with self.assertRaises(AccessError): + quant.action_apply_inventory() + + def test_privileged_user_can_apply_adjustment(self): + """Test that privileged users can apply adjustments""" + quant = self.quant.with_user(self.privileged_user).with_context( + inventory_mode=True + ) + + # Set inventory quantity + quant.write({"inventory_quantity": 90.0}) + + # Should be able to apply the adjustment + quant.action_apply_inventory() + + # Quantity should be updated + self.assertEqual(quant.quantity, 90.0) + self.assertEqual(quant.inventory_quantity_set, False) + + def test_privileged_user_can_create_quants_in_inventory_mode(self): + """Test that privileged users can create new quants in inventory mode""" + # Create a new product + new_product = self.env["product.product"].create( + { + "name": "Privileged Product", + "type": "consu", + "is_storable": True, + } + ) + + # Privileged user should be able to create a quant in inventory mode + quant = self.env["stock.quant"].with_user(self.privileged_user).with_context( + inventory_mode=True + ).create( + { + "product_id": new_product.id, + "location_id": self.stock_location.id, + "quantity": 50.0, + } + ) + + self.assertEqual(quant.quantity, 50.0) + self.assertEqual(quant.product_id, new_product) + + def test_automatic_operations_not_affected(self): + """Test that automatic stock operations work normally""" + # Create a stock move (simulating a picking or manufacturing operation) + # These operations don't set inventory_mode context + move = self.env["stock.move"].create( + { + "name": "Test Move", + "product_id": self.product.id, + "product_uom_qty": 10.0, + "product_uom": self.product.uom_id.id, + "location_id": self.env.ref("stock.stock_location_suppliers").id, + "location_dest_id": self.stock_location.id, + } + ) + + move._action_confirm() + move._action_assign() + # Set quantity on the move itself + move.quantity = 10.0 + move.picked = True + move._action_done() + + # Quant quantity should be updated automatically + # Find the quant for this product in stock location + quant = self.env["stock.quant"].search( + [ + ("product_id", "=", self.product.id), + ("location_id", "=", self.stock_location.id), + ] + ) + self.assertEqual(quant.quantity, 110.0) + + def test_regular_user_can_view_quantities(self): + """Test that regular users can view all quantity fields""" + quant = self.quant.with_user(self.inventory_user) + + # Should be able to read all fields + self.assertEqual(quant.quantity, 100.0) + self.assertEqual(quant.inventory_quantity, 0.0) + + # Should be able to read in inventory mode too + quant_inv_mode = quant.with_context(inventory_mode=True) + self.assertEqual(quant_inv_mode.quantity, 100.0) + + def test_non_inventory_mode_writes_allowed(self): + """Test that quantity writes outside inventory mode are not restricted""" + # Even regular users can write quantity when not in inventory_mode + # (though they typically wouldn't have access to do this via UI) + quant = self.quant.sudo() + + # Without inventory_mode context, write should work + quant.write({"quantity": 95.0}) + + self.assertEqual(quant.quantity, 95.0) + + def test_inventory_mode_detection(self): + """Test that _is_inventory_mode() works correctly""" + quant = self.quant.with_user(self.inventory_user) + + # Without inventory_mode context + self.assertFalse(quant._is_inventory_mode()) + + # With inventory_mode context + quant_inv = quant.with_context(inventory_mode=True) + self.assertTrue(quant_inv._is_inventory_mode()) + + # User without stock_user group shouldn't trigger inventory mode + basic_user = self.env["res.users"].create( + { + "name": "Basic User", + "login": "basic_user", + "email": "basic@test.com", + "groups_id": [(6, 0, [self.env.ref("base.group_user").id])], + } + ) + quant_basic = self.quant.with_user(basic_user).with_context(inventory_mode=True) + self.assertFalse(quant_basic._is_inventory_mode()) + + def test_multiple_quants_adjustment(self): + """Test applying adjustments to multiple quants at once""" + # Create another product to avoid quant merging + product2 = self.env["product.product"].create( + { + "name": "Test Product 2", + "type": "consu", + "is_storable": True, + } + ) + + # Create another quant with different product + quant2 = ( + self.env["stock.quant"] + .sudo() + .create( + { + "product_id": product2.id, + "location_id": self.stock_location.id, + "quantity": 50.0, + } + ) + ) + + # Set inventory quantities on both quants + self.quant.with_user(self.privileged_user).with_context( + inventory_mode=True + ).write({"inventory_quantity": 80.0}) + quant2.with_user(self.privileged_user).with_context(inventory_mode=True).write( + {"inventory_quantity": 40.0} + ) + + # Apply all at once + quants = ( + (self.quant | quant2) + .with_user(self.privileged_user) + .with_context(inventory_mode=True) + ) + quants.action_apply_inventory() + + self.assertEqual(self.quant.quantity, 80.0) + self.assertEqual(quant2.quantity, 40.0) + + def test_regular_user_multiple_quants_blocked(self): + """Test that regular users cannot apply multiple quants""" + # Create another product to avoid quant merging + product2 = self.env["product.product"].create( + { + "name": "Test Product 3", + "type": "consu", + "is_storable": True, + } + ) + + quant2 = ( + self.env["stock.quant"] + .sudo() + .create( + { + "product_id": product2.id, + "location_id": self.stock_location.id, + "quantity": 50.0, + } + ) + ) + + # Set inventory quantities + self.quant.with_user(self.inventory_user).with_context( + inventory_mode=True + ).write({"inventory_quantity": 80.0}) + quant2.with_user(self.inventory_user).with_context(inventory_mode=True).write( + {"inventory_quantity": 40.0} + ) + + # Trying to apply should fail + quants = ( + (self.quant | quant2) + .with_user(self.inventory_user) + .with_context(inventory_mode=True) + ) + with self.assertRaises(AccessError): + quants.action_apply_inventory()