diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bbe434ec4..b9861c4e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/docs/docs/settings/permissions.md b/docs/docs/settings/permissions.md index d6b717ed62..6e169af8f5 100644 --- a/docs/docs/settings/permissions.md +++ b/docs/docs/settings/permissions.md @@ -28,7 +28,8 @@ InvenTree functionality is split into a number of distinct roles. A group will h | Role | Description | | ---- | ----------- | | **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 Category** | The *part category* role is related to accessing Part Category data | | **Purchase Order** | The *purchase* role is related to accessing Purchase Order data | diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 476e408b8e..84dcb6b9b5 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # 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.""" 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 - 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 diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index dbf8cf815a..0b543e4871 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -599,6 +599,7 @@ class PartValidateBOM(RetrieveUpdateAPI): queryset = Part.objects.all() serializer_class = part_serializers.PartBomValidateSerializer + role_required = 'bom.change' @extend_schema( responses={ diff --git a/src/backend/InvenTree/part/migrations/0148_auto_20260427_2233.py b/src/backend/InvenTree/part/migrations/0148_auto_20260427_2233.py new file mode 100644 index 0000000000..1ea49bebf4 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0148_auto_20260427_2233.py @@ -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), + ] diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index da8bada994..1ba1caad90 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -1010,7 +1010,7 @@ class PartAPITest(PartAPITestBase): filters = {'active': True, 'assembly': True, 'bom_valid': True} # 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) @@ -1038,6 +1038,14 @@ class PartAPITest(PartAPITestBase): # Test the BOM validation API endpoint 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 self.assertEqual(data['bom_validated'], True) @@ -2913,6 +2921,12 @@ class BomItemTest(InvenTreeAPITestCase): # Count first, operate directly on Model 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 response = self.get(url, expected_code=200) self.assertEqual(len(response.data), countbefore) diff --git a/src/backend/InvenTree/users/migrations/0011_auto_20240523_1640.py b/src/backend/InvenTree/users/migrations/0011_auto_20240523_1640.py index 6dd75904a9..428edaecfd 100644 --- a/src/backend/InvenTree/users/migrations/0011_auto_20240523_1640.py +++ b/src/backend/InvenTree/users/migrations/0011_auto_20240523_1640.py @@ -1,11 +1,16 @@ # Generated by Django 4.2.12 on 2024-05-23 16:40 +import structlog + from importlib import import_module from django.conf import settings from django.db import migrations +logger = structlog.getLogger('inventree') + + def clear_sessions(apps, schema_editor): # pragma: no cover """Clear all user sessions.""" @@ -16,7 +21,7 @@ def clear_sessions(apps, schema_editor): # pragma: no cover try: engine = import_module(settings.SESSION_ENGINE) 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: # Database may not be ready yet, so this does not matter anyhow pass diff --git a/src/backend/InvenTree/users/oauth2_scopes.py b/src/backend/InvenTree/users/oauth2_scopes.py index b5e687ce1f..123cb8d528 100644 --- a/src/backend/InvenTree/users/oauth2_scopes.py +++ b/src/backend/InvenTree/users/oauth2_scopes.py @@ -15,6 +15,7 @@ _roles = { 'part': 'Role Parts', 'stock_location': 'Role Stock Locations', 'stock': 'Role Stock Items', + 'bom': 'Role Bills of Material', 'build': 'Role Build Orders', 'purchase_order': 'Role Purchase Orders', 'sales_order': 'Role Sales Orders', diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index ed70b79b4d..272a30d452 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -12,6 +12,7 @@ class RuleSetEnum(StringEnum): ADMIN = 'admin' PART_CATEGORY = 'part_category' PART = 'part' + BOM = 'bom' STOCK_LOCATION = 'stock_location' STOCK = 'stock' BUILD = 'build' @@ -26,6 +27,7 @@ RULESET_CHOICES = [ (RuleSetEnum.ADMIN, _('Admin')), (RuleSetEnum.PART_CATEGORY, _('Part Categories')), (RuleSetEnum.PART, _('Parts')), + (RuleSetEnum.BOM, _('Bills of Material')), (RuleSetEnum.STOCK_LOCATION, _('Stock Locations')), (RuleSetEnum.STOCK, _('Stock Items')), (RuleSetEnum.BUILD, _('Build Orders')), @@ -94,6 +96,18 @@ def get_ruleset_models() -> dict: 'django_mailbox_messageattachment', '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: [ 'part_partcategory', 'part_partcategoryparametertemplate', @@ -102,8 +116,6 @@ def get_ruleset_models() -> dict: RuleSetEnum.PART: [ 'part_part', 'part_partpricing', - 'part_bomitem', - 'part_bomitemsubstitute', 'part_partsellpricebreak', 'part_partinternalpricebreak', 'part_parttesttemplate', @@ -120,17 +132,6 @@ def get_ruleset_models() -> dict: 'stock_stockitemtracking', '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: [ 'company_company', 'company_contact', diff --git a/src/frontend/lib/enums/Roles.tsx b/src/frontend/lib/enums/Roles.tsx index efa6c4a04b..0f5aedd94c 100644 --- a/src/frontend/lib/enums/Roles.tsx +++ b/src/frontend/lib/enums/Roles.tsx @@ -5,6 +5,7 @@ import { t } from '@lingui/core/macro'; */ export enum UserRoles { admin = 'admin', + bom = 'bom', build = 'build', part = 'part', part_category = 'part_category', diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 2dfc6e1784..645b305a52 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -164,6 +164,8 @@ function BomValidationInformation({ bomInformation: UseInstanceResult; partId: number; }) { + const user = useUserState(); + const [taskId, setTaskId] = useState(''); useBackgroundTask({ @@ -232,14 +234,15 @@ function BomValidationInformation({ <> {validateBom.modal} - {!bomInformation.instance?.bom_validated && ( - } - color='green' - tooltip={t`Validate BOM`} - onClick={validateBom.open} - /> - )} + {!bomInformation.instance?.bom_validated && + user.hasChangeRole(UserRoles.bom) && ( + } + color='green' + tooltip={t`Validate BOM`} + onClick={validateBom.open} + /> + )} ), icon: , - hidden: !part.assembly, + hidden: !part.assembly || !user.hasViewRole(UserRoles.bom), content: part?.pk ? ( {bomInformation.isLoaded && diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx index ec78463b81..35c723969c 100644 --- a/src/frontend/src/tables/bom/BomTable.tsx +++ b/src/frontend/src/tables/bom/BomTable.tsx @@ -607,14 +607,14 @@ export function BomTable({ hidden: partLocked || record.validated || - !user.hasChangeRole(UserRoles.part), + !user.hasChangeRole(UserRoles.bom), icon: , onClick: () => { validateBomItem(record); } }, RowEditAction({ - hidden: partLocked || !user.hasChangeRole(UserRoles.part), + hidden: partLocked || !user.hasChangeRole(UserRoles.bom), onClick: () => { setSelectedBomItem(record); editBomItem.open(); @@ -623,7 +623,7 @@ export function BomTable({ { title: t`Edit Substitutes`, color: 'blue', - hidden: partLocked || !user.hasAddRole(UserRoles.part), + hidden: partLocked || !user.hasAddRole(UserRoles.bom), icon: , onClick: () => { setSelectedBomItem(record); @@ -631,7 +631,7 @@ export function BomTable({ } }, RowDeleteAction({ - hidden: partLocked || !user.hasDeleteRole(UserRoles.part), + hidden: partLocked || !user.hasDeleteRole(UserRoles.bom), onClick: () => { setSelectedBomItem(record); deleteBomItem.open(); @@ -649,7 +649,7 @@ export function BomTable({ tooltip={t`Add BOM Items`} position='bottom-start' icon={} - hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.part)} + hidden={!isEditing || partLocked || !user.hasAddRole(UserRoles.bom)} actions={[ { name: t`Add BOM Item`, @@ -667,7 +667,7 @@ export function BomTable({ />,