mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-28 05:44:27 +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:
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -599,6 +599,7 @@ class PartValidateBOM(RetrieveUpdateAPI):
|
||||
|
||||
queryset = Part.objects.all()
|
||||
serializer_class = part_serializers.PartBomValidateSerializer
|
||||
role_required = 'bom.change'
|
||||
|
||||
@extend_schema(
|
||||
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),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -164,6 +164,8 @@ function BomValidationInformation({
|
||||
bomInformation: UseInstanceResult;
|
||||
partId: number;
|
||||
}) {
|
||||
const user = useUserState();
|
||||
|
||||
const [taskId, setTaskId] = useState<string>('');
|
||||
|
||||
useBackgroundTask({
|
||||
@@ -232,14 +234,15 @@ function BomValidationInformation({
|
||||
<>
|
||||
{validateBom.modal}
|
||||
<Group gap='xs' justify='flex-end'>
|
||||
{!bomInformation.instance?.bom_validated && (
|
||||
<ActionButton
|
||||
icon={<IconCircleCheck />}
|
||||
color='green'
|
||||
tooltip={t`Validate BOM`}
|
||||
onClick={validateBom.open}
|
||||
/>
|
||||
)}
|
||||
{!bomInformation.instance?.bom_validated &&
|
||||
user.hasChangeRole(UserRoles.bom) && (
|
||||
<ActionButton
|
||||
icon={<IconCircleCheck />}
|
||||
color='green'
|
||||
tooltip={t`Validate BOM`}
|
||||
onClick={validateBom.open}
|
||||
/>
|
||||
)}
|
||||
<HoverCard position='bottom-end'>
|
||||
<HoverCard.Target>
|
||||
<ActionIcon
|
||||
@@ -811,7 +814,7 @@ export default function PartDetail() {
|
||||
/>
|
||||
),
|
||||
icon: <IconListTree />,
|
||||
hidden: !part.assembly,
|
||||
hidden: !part.assembly || !user.hasViewRole(UserRoles.bom),
|
||||
content: part?.pk ? (
|
||||
<Stack gap='xs'>
|
||||
{bomInformation.isLoaded &&
|
||||
|
||||
@@ -607,14 +607,14 @@ export function BomTable({
|
||||
hidden:
|
||||
partLocked ||
|
||||
record.validated ||
|
||||
!user.hasChangeRole(UserRoles.part),
|
||||
!user.hasChangeRole(UserRoles.bom),
|
||||
icon: <IconCircleCheck />,
|
||||
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: <IconSwitch3 />,
|
||||
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={<IconPlus />}
|
||||
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({
|
||||
/>,
|
||||
<ActionButton
|
||||
key='edit-bom'
|
||||
hidden={partLocked || !user.hasChangeRole(UserRoles.part) || isEditing}
|
||||
hidden={partLocked || !user.hasChangeRole(UserRoles.bom) || isEditing}
|
||||
tooltip={t`Edit BOM`}
|
||||
icon={<IconEdit />}
|
||||
onClick={() => {
|
||||
@@ -731,7 +731,7 @@ export function BomTable({
|
||||
rowActions: isEditing ? rowActions : undefined,
|
||||
enableSelection: isEditing && !partLocked,
|
||||
enableBulkDelete:
|
||||
isEditing && !partLocked && user.hasDeleteRole(UserRoles.part),
|
||||
isEditing && !partLocked && user.hasDeleteRole(UserRoles.bom),
|
||||
enableDownload: true,
|
||||
rowExpansion: isEditing ? undefined : rowExpansion
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user