[ADD] stock_inventory_adjustment_security: limit who can adjust stock

This commit is contained in:
Marc Durepos 2025-10-02 17:03:24 -04:00
parent 3f5c5cc038
commit a080586ef4
8 changed files with 523 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1 @@
from . import models

View file

@ -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,
}

View file

@ -0,0 +1 @@
from . import stock_quant

View file

@ -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)

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="group_inventory_manual_adjustments" model="res.groups">
<field name="name">Manual Adjustments</field>
<field name="category_id" ref="base.module_category_inventory_inventory"/>
<field name="implied_ids" eval="[(4, ref('stock.group_stock_user'))]"/>
<field name="comment">
Users in this group can apply manual inventory adjustments.
Regular inventory users can count inventory but cannot apply the adjustments.
</field>
</record>
</odoo>

View file

@ -0,0 +1 @@
from . import test_inventory_adjustment_security

View file

@ -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()