mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-06 09:43:38 +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:
@@ -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