2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-28 13:54:25 +00:00

BOM Ruleset (#11825)

* Add BOM role

* Adjust UI permissions

* Adjust docs

* Add data migratoin

* Specify role for BOM validation

* Tweak old migrati

* Fix role_required

* Update API version and CHANGELOG

Co-authored-by: Copilot <copilot@github.com>

* Update unit tests

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Oliver
2026-04-28 12:29:57 +10:00
committed by GitHub
parent 6e4ffcb6d2
commit 2b6952eabd
12 changed files with 107 additions and 34 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
[#11816](https://github.com/inventree/InvenTree/pull/11816) makes the `issued_by` field on the `Build` API read only, and instead sets the `issued_by` field to the current user when a build is created. This change was made to ensure that the `issued_by` field accurately reflects the user who created the build, and to prevent users from setting this field to an arbitrary value when creating or updating a build. - [#11825](https://github.com/inventree/InvenTree/pull/11825) adds a new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management. This allows for more granular control over user permissions, allowing users to have different levels of access to part management and BOM management functionality.
- [#11816](https://github.com/inventree/InvenTree/pull/11816) makes the `issued_by` field on the `Build` API read only, and instead sets the `issued_by` field to the current user when a build is created. This change was made to ensure that the `issued_by` field accurately reflects the user who created the build, and to prevent users from setting this field to an arbitrary value when creating or updating a build.
### Removed ### Removed
+2 -1
View File
@@ -28,7 +28,8 @@ InvenTree functionality is split into a number of distinct roles. A group will h
| Role | Description | | Role | Description |
| ---- | ----------- | | ---- | ----------- |
| **Admin** | The *admin* role is related to assigning user permissions. | | **Admin** | The *admin* role is related to assigning user permissions. |
| **Build** | The *build* role is related to accessing Build Order and Bill of Materials data | | **BOM** | The *bom* role is related to accessing Bill of Materials data |
| **Build** | The *build* role is related to accessing manufacturing / Build Order |
| **Part** | The *part* role is related to accessing Part data | | **Part** | The *part* role is related to accessing Part data |
| **Part Category** | The *part category* role is related to accessing Part Category data | | **Part Category** | The *part category* role is related to accessing Part Category data |
| **Purchase Order** | The *purchase* role is related to accessing Purchase Order data | | **Purchase Order** | The *purchase* role is related to accessing Purchase Order data |
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 480 INVENTREE_API_VERSION = 481
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v481 -> 2026-04-28 : https://github.com/inventree/InvenTree/pull/11825
- Adds new "bom" ruleset and associated permissions for BOM management, separate from the "part" ruleset which remains focused on part management
v480 -> 2026-04-27 : https://github.com/inventree/InvenTree/pull/11816 v480 -> 2026-04-27 : https://github.com/inventree/InvenTree/pull/11816
- The "issued_by" field on the Build API endpoint is now read-only, and is automatically set to the current user when a build is created - The "issued_by" field on the Build API endpoint is now read-only, and is automatically set to the current user when a build is created
+1
View File
@@ -599,6 +599,7 @@ class PartValidateBOM(RetrieveUpdateAPI):
queryset = Part.objects.all() queryset = Part.objects.all()
serializer_class = part_serializers.PartBomValidateSerializer serializer_class = part_serializers.PartBomValidateSerializer
role_required = 'bom.change'
@extend_schema( @extend_schema(
responses={ responses={
@@ -0,0 +1,42 @@
# Generated by Django 5.2.13 on 2026-04-27 22:33
from django.db import migrations
def update_bom_role(apps, schema_editor):
"""Update the ruleset for the BOM role.
As the 'bom' role was previously covered by the 'part' role,
we need to update the ruleset to include the correct models.
"""
from django.contrib.auth.models import Group
from users.ruleset import RuleSetEnum
from users.models import RuleSet
# For each existing group, create a new 'bom' ruleset
for group in Group.objects.all():
# Find a matching 'part' ruleset for this group
if ruleset := RuleSet.objects.filter(name=RuleSetEnum.PART, group=group).first():
# A 'part' ruleset exists for this group - create a new 'bom' ruleset based on this
ruleset.pk = None # Create a new instance
ruleset.name = RuleSetEnum.BOM
ruleset.save()
else:
# No 'part' ruleset exists for this group - create a default 'bom' ruleset
RuleSet.objects.create(
name=RuleSetEnum.BOM,
group=group
)
class Migration(migrations.Migration):
dependencies = [
("part", "0147_remove_part_default_supplier"),
]
operations = [
migrations.RunPython(update_bom_role, reverse_code=migrations.RunPython.noop),
]
+15 -1
View File
@@ -1010,7 +1010,7 @@ class PartAPITest(PartAPITestBase):
filters = {'active': True, 'assembly': True, 'bom_valid': True} filters = {'active': True, 'assembly': True, 'bom_valid': True}
# Initially, there are no parts with a valid BOM # Initially, there are no parts with a valid BOM
response = self.get(url, filters) response = self.get(url, filters, expected_code=200)
self.assertEqual(len(response.data), 0) self.assertEqual(len(response.data), 0)
@@ -1038,6 +1038,14 @@ class PartAPITest(PartAPITestBase):
# Test the BOM validation API endpoint # Test the BOM validation API endpoint
bom_url = reverse('api-part-bom-validate', kwargs={'pk': assembly.pk}) bom_url = reverse('api-part-bom-validate', kwargs={'pk': assembly.pk})
# Initially, we do not have the required role permissions
self.get(bom_url, expected_code=403)
# Add required role
self.assignRole('bom.add')
# Now we should be able to validate the BOM via the API
data = self.get(bom_url, expected_code=200).data data = self.get(bom_url, expected_code=200).data
self.assertEqual(data['bom_validated'], True) self.assertEqual(data['bom_validated'], True)
@@ -2913,6 +2921,12 @@ class BomItemTest(InvenTreeAPITestCase):
# Count first, operate directly on Model # Count first, operate directly on Model
countbefore = BomItemSubstitute.objects.count() countbefore = BomItemSubstitute.objects.count()
# Initially, the user does not have the required permissions
self.get(url, expected_code=403)
# Assign the permission to view the substitute list
self.assignRole('bom.add')
# Now, make sure API returns the same count # Now, make sure API returns the same count
response = self.get(url, expected_code=200) response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), countbefore) self.assertEqual(len(response.data), countbefore)
@@ -1,11 +1,16 @@
# Generated by Django 4.2.12 on 2024-05-23 16:40 # Generated by Django 4.2.12 on 2024-05-23 16:40
import structlog
from importlib import import_module from importlib import import_module
from django.conf import settings from django.conf import settings
from django.db import migrations from django.db import migrations
logger = structlog.getLogger('inventree')
def clear_sessions(apps, schema_editor): # pragma: no cover def clear_sessions(apps, schema_editor): # pragma: no cover
"""Clear all user sessions.""" """Clear all user sessions."""
@@ -16,7 +21,7 @@ def clear_sessions(apps, schema_editor): # pragma: no cover
try: try:
engine = import_module(settings.SESSION_ENGINE) engine = import_module(settings.SESSION_ENGINE)
engine.SessionStore.clear_expired() engine.SessionStore.clear_expired()
print('\nCleared all user sessions to deal with GHSA-2crp-q9pc-457j') logger.info('Cleared all user sessions to deal with GHSA-2crp-q9pc-457j')
except Exception: except Exception:
# Database may not be ready yet, so this does not matter anyhow # Database may not be ready yet, so this does not matter anyhow
pass pass
@@ -15,6 +15,7 @@ _roles = {
'part': 'Role Parts', 'part': 'Role Parts',
'stock_location': 'Role Stock Locations', 'stock_location': 'Role Stock Locations',
'stock': 'Role Stock Items', 'stock': 'Role Stock Items',
'bom': 'Role Bills of Material',
'build': 'Role Build Orders', 'build': 'Role Build Orders',
'purchase_order': 'Role Purchase Orders', 'purchase_order': 'Role Purchase Orders',
'sales_order': 'Role Sales Orders', 'sales_order': 'Role Sales Orders',
+14 -13
View File
@@ -12,6 +12,7 @@ class RuleSetEnum(StringEnum):
ADMIN = 'admin' ADMIN = 'admin'
PART_CATEGORY = 'part_category' PART_CATEGORY = 'part_category'
PART = 'part' PART = 'part'
BOM = 'bom'
STOCK_LOCATION = 'stock_location' STOCK_LOCATION = 'stock_location'
STOCK = 'stock' STOCK = 'stock'
BUILD = 'build' BUILD = 'build'
@@ -26,6 +27,7 @@ RULESET_CHOICES = [
(RuleSetEnum.ADMIN, _('Admin')), (RuleSetEnum.ADMIN, _('Admin')),
(RuleSetEnum.PART_CATEGORY, _('Part Categories')), (RuleSetEnum.PART_CATEGORY, _('Part Categories')),
(RuleSetEnum.PART, _('Parts')), (RuleSetEnum.PART, _('Parts')),
(RuleSetEnum.BOM, _('Bills of Material')),
(RuleSetEnum.STOCK_LOCATION, _('Stock Locations')), (RuleSetEnum.STOCK_LOCATION, _('Stock Locations')),
(RuleSetEnum.STOCK, _('Stock Items')), (RuleSetEnum.STOCK, _('Stock Items')),
(RuleSetEnum.BUILD, _('Build Orders')), (RuleSetEnum.BUILD, _('Build Orders')),
@@ -94,6 +96,18 @@ def get_ruleset_models() -> dict:
'django_mailbox_messageattachment', 'django_mailbox_messageattachment',
'django_mailbox_message', 'django_mailbox_message',
], ],
RuleSetEnum.BOM: ['part_bomitem', 'part_bomitemsubstitute'],
RuleSetEnum.BUILD: [
'part_part',
'part_partcategory',
'part_bomitem',
'part_bomitemsubstitute',
'build_build',
'build_builditem',
'build_buildline',
'stock_stockitem',
'stock_stocklocation',
],
RuleSetEnum.PART_CATEGORY: [ RuleSetEnum.PART_CATEGORY: [
'part_partcategory', 'part_partcategory',
'part_partcategoryparametertemplate', 'part_partcategoryparametertemplate',
@@ -102,8 +116,6 @@ def get_ruleset_models() -> dict:
RuleSetEnum.PART: [ RuleSetEnum.PART: [
'part_part', 'part_part',
'part_partpricing', 'part_partpricing',
'part_bomitem',
'part_bomitemsubstitute',
'part_partsellpricebreak', 'part_partsellpricebreak',
'part_partinternalpricebreak', 'part_partinternalpricebreak',
'part_parttesttemplate', 'part_parttesttemplate',
@@ -120,17 +132,6 @@ def get_ruleset_models() -> dict:
'stock_stockitemtracking', 'stock_stockitemtracking',
'stock_stockitemtestresult', 'stock_stockitemtestresult',
], ],
RuleSetEnum.BUILD: [
'part_part',
'part_partcategory',
'part_bomitem',
'part_bomitemsubstitute',
'build_build',
'build_builditem',
'build_buildline',
'stock_stockitem',
'stock_stocklocation',
],
RuleSetEnum.PURCHASE_ORDER: [ RuleSetEnum.PURCHASE_ORDER: [
'company_company', 'company_company',
'company_contact', 'company_contact',
+1
View File
@@ -5,6 +5,7 @@ import { t } from '@lingui/core/macro';
*/ */
export enum UserRoles { export enum UserRoles {
admin = 'admin', admin = 'admin',
bom = 'bom',
build = 'build', build = 'build',
part = 'part', part = 'part',
part_category = 'part_category', part_category = 'part_category',
+5 -2
View File
@@ -164,6 +164,8 @@ function BomValidationInformation({
bomInformation: UseInstanceResult; bomInformation: UseInstanceResult;
partId: number; partId: number;
}) { }) {
const user = useUserState();
const [taskId, setTaskId] = useState<string>(''); const [taskId, setTaskId] = useState<string>('');
useBackgroundTask({ useBackgroundTask({
@@ -232,7 +234,8 @@ function BomValidationInformation({
<> <>
{validateBom.modal} {validateBom.modal}
<Group gap='xs' justify='flex-end'> <Group gap='xs' justify='flex-end'>
{!bomInformation.instance?.bom_validated && ( {!bomInformation.instance?.bom_validated &&
user.hasChangeRole(UserRoles.bom) && (
<ActionButton <ActionButton
icon={<IconCircleCheck />} icon={<IconCircleCheck />}
color='green' color='green'
@@ -811,7 +814,7 @@ export default function PartDetail() {
/> />
), ),
icon: <IconListTree />, icon: <IconListTree />,
hidden: !part.assembly, hidden: !part.assembly || !user.hasViewRole(UserRoles.bom),
content: part?.pk ? ( content: part?.pk ? (
<Stack gap='xs'> <Stack gap='xs'>
{bomInformation.isLoaded && {bomInformation.isLoaded &&
+7 -7
View File
@@ -607,14 +607,14 @@ export function BomTable({
hidden: hidden:
partLocked || partLocked ||
record.validated || record.validated ||
!user.hasChangeRole(UserRoles.part), !user.hasChangeRole(UserRoles.bom),
icon: <IconCircleCheck />, icon: <IconCircleCheck />,
onClick: () => { onClick: () => {
validateBomItem(record); validateBomItem(record);
} }
}, },
RowEditAction({ RowEditAction({
hidden: partLocked || !user.hasChangeRole(UserRoles.part), hidden: partLocked || !user.hasChangeRole(UserRoles.bom),
onClick: () => { onClick: () => {
setSelectedBomItem(record); setSelectedBomItem(record);
editBomItem.open(); editBomItem.open();
@@ -623,7 +623,7 @@ export function BomTable({
{ {
title: t`Edit Substitutes`, title: t`Edit Substitutes`,
color: 'blue', color: 'blue',
hidden: partLocked || !user.hasAddRole(UserRoles.part), hidden: partLocked || !user.hasAddRole(UserRoles.bom),
icon: <IconSwitch3 />, icon: <IconSwitch3 />,
onClick: () => { onClick: () => {
setSelectedBomItem(record); setSelectedBomItem(record);
@@ -631,7 +631,7 @@ export function BomTable({
} }
}, },
RowDeleteAction({ RowDeleteAction({
hidden: partLocked || !user.hasDeleteRole(UserRoles.part), hidden: partLocked || !user.hasDeleteRole(UserRoles.bom),
onClick: () => { onClick: () => {
setSelectedBomItem(record); setSelectedBomItem(record);
deleteBomItem.open(); deleteBomItem.open();
@@ -649,7 +649,7 @@ export function BomTable({
tooltip={t`Add BOM Items`} tooltip={t`Add BOM Items`}
position='bottom-start' position='bottom-start'
icon={<IconPlus />} icon={<IconPlus />}
hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.part)} hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.bom)}
actions={[ actions={[
{ {
name: t`Add BOM Item`, name: t`Add BOM Item`,
@@ -667,7 +667,7 @@ export function BomTable({
/>, />,
<ActionButton <ActionButton
key='edit-bom' key='edit-bom'
hidden={partLocked || !user.hasChangeRole(UserRoles.part) || isEditing} hidden={partLocked || !user.hasChangeRole(UserRoles.bom) || isEditing}
tooltip={t`Edit BOM`} tooltip={t`Edit BOM`}
icon={<IconEdit />} icon={<IconEdit />}
onClick={() => { onClick={() => {
@@ -731,7 +731,7 @@ export function BomTable({
rowActions: isEditing ? rowActions : undefined, rowActions: isEditing ? rowActions : undefined,
enableSelection: isEditing && !partLocked, enableSelection: isEditing && !partLocked,
enableBulkDelete: enableBulkDelete:
isEditing && !partLocked && user.hasDeleteRole(UserRoles.part), isEditing && !partLocked && user.hasDeleteRole(UserRoles.bom),
enableDownload: true, enableDownload: true,
rowExpansion: isEditing ? undefined : rowExpansion rowExpansion: isEditing ? undefined : rowExpansion
}} }}