2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-06 23:21:59 +00:00

[feature] Consumable part (#12295)

* Add "consumable" field to the Part model

* Add frontend integration

* Add badge

* Add docs

* Updated docs

* Add API filtering for new field

* Adjust API filters

* Update backend logic

* Adjust frontend

* Additional unit tests

* Update API version

* update test

* Add consumable icon

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2026-07-06 21:12:24 +10:00
committed by GitHub
parent 47ed86ef9a
commit e6a12564f3
23 changed files with 365 additions and 21 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 515
INVENTREE_API_VERSION = 516
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v516 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12295
- Adds "consumable" field to the Part model and API endpoints
v515 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12298
- Change the file fields definition to binary (from uri) in the upload requests
+14 -1
View File
@@ -457,8 +457,21 @@ class BuildLineFilter(FilterSet):
# Fields on related models
consumable = rest_filters.BooleanFilter(
label=_('Consumable'), field_name='bom_item__consumable'
label=_('Consumable'), method='filter_consumable'
)
def filter_consumable(self, queryset, name, value):
"""Filter the queryset based on the "effective" consumable status of the BOM item.
A BuildLine is considered "consumable" if either the BOM item itself,
or the underlying part, is marked as consumable.
"""
return queryset.filter(
part_models.BomItem.consumable_filter(
consumable=str2bool(value), prefix='bom_item__'
)
)
optional = rest_filters.BooleanFilter(
label=_('Optional'), field_name='bom_item__optional'
)
+19 -8
View File
@@ -974,7 +974,9 @@ class Build(
items_to_delete = []
lines = self.untracked_line_items.all()
lines = lines.exclude(bom_item__consumable=True)
lines = lines.exclude(
part.models.BomItem.consumable_filter(prefix='bom_item__')
)
lines = lines.annotate(allocated=annotate_allocated_quantity())
for build_line in lines:
@@ -1254,13 +1256,16 @@ class Build(
return allocations
tracked_line_items = self.tracked_line_items.filter(
bom_item__consumable=False, bom_item__sub_part__virtual=False
part.models.BomItem.consumable_filter(
consumable=False, prefix='bom_item__'
),
bom_item__sub_part__virtual=False,
)
for line_item in tracked_line_items:
bom_item = line_item.bom_item
if bom_item.consumable:
if bom_item.is_consumable:
# Do not auto-allocate stock to consumable BOM items
continue
@@ -1384,7 +1389,7 @@ class Build(
# Find the referenced BomItem
bom_item = line_item.bom_item
if bom_item.consumable:
if bom_item.is_consumable:
# Do not auto-allocate stock to consumable BOM items
continue
@@ -1504,7 +1509,9 @@ class Build(
lines = self.build_lines.all()
# Remove any 'consumable' line items
lines = lines.exclude(bom_item__consumable=True)
lines = lines.exclude(
part.models.BomItem.consumable_filter(prefix='bom_item__')
)
if tracked is True:
lines = lines.filter(bom_item__sub_part__trackable=True)
@@ -1541,7 +1548,9 @@ class Build(
we need to test all "trackable" BuildLine objects
"""
lines = self.build_lines.filter(bom_item__sub_part__trackable=True)
lines = lines.exclude(bom_item__consumable=True)
lines = lines.exclude(
part.models.BomItem.consumable_filter(prefix='bom_item__')
)
# Find any lines which have not been fully allocated
for line in lines:
@@ -1565,7 +1574,9 @@ class Build(
Returns:
True if any BuildLine has been over-allocated.
"""
lines = self.build_lines.all().exclude(bom_item__consumable=True)
lines = self.build_lines.all().exclude(
part.models.BomItem.consumable_filter(prefix='bom_item__')
)
lines = lines.prefetch_related('allocations')
@@ -1794,7 +1805,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
def is_fully_allocated(self) -> bool:
"""Return True if this BuildLine is fully allocated."""
if self.bom_item.consumable:
if self.bom_item.is_consumable:
return True
required = max(0, self.quantity - self.consumed)
+2 -2
View File
@@ -996,7 +996,7 @@ class BuildAllocationSerializer(serializers.Serializer):
output = item.get('output', None)
# Ignore allocation for consumable BOM items
if build_line.bom_item.consumable:
if build_line.bom_item.is_consumable:
continue
params = {
@@ -1389,7 +1389,7 @@ class BuildLineSerializer(
source='bom_item.reference', label=_('Reference'), read_only=True
)
consumable = serializers.BooleanField(
source='bom_item.consumable', label=_('Consumable'), read_only=True
source='bom_item.is_consumable', label=_('Consumable'), read_only=True
)
optional = serializers.BooleanField(
source='bom_item.optional', label=_('Optional'), read_only=True
+71
View File
@@ -1887,6 +1887,77 @@ class BuildLineTests(BuildAPITest):
self.assertSetEqual(false_ids, expected_false)
self.assertSetEqual(true_ids | false_ids, {line.pk for line in lines})
def test_filter_consumable_via_part(self):
"""Filter BuildLine objects by 'consumable' status, accounting for the underlying part.
A BuildLine should be treated as 'consumable' if either the BOM line
itself is marked as consumable, or the underlying part is marked as consumable.
"""
assembly = Part.objects.create(
name='Consumable Filter Assembly',
description='Assembly for consumable filter tests',
assembly=True,
)
plain = Part.objects.create(
name='Consumable Filter Plain Component',
description='A regular component',
component=True,
)
consumable_part = Part.objects.create(
name='Consumable Filter Consumable Part',
description='A part marked as consumable',
component=True,
consumable=True,
)
consumable_line_part = Part.objects.create(
name='Consumable Filter Consumable BOM Line',
description='A part which is consumable only via its BOM line',
component=True,
)
bom_item_plain = BomItem.objects.create(
part=assembly, sub_part=plain, quantity=1
)
bom_item_part_consumable = BomItem.objects.create(
part=assembly, sub_part=consumable_part, quantity=1
)
bom_item_line_consumable = BomItem.objects.create(
part=assembly, sub_part=consumable_line_part, quantity=1, consumable=True
)
build = Build.objects.create(
part=assembly,
reference='BO-9997',
quantity=1,
title='Consumable Filter Build',
)
url = reverse('api-build-line-list')
response = self.get(url, data={'build': build.pk, 'consumable': True})
returned_bom_items = {item['bom_item'] for item in response.data}
self.assertIn(bom_item_part_consumable.pk, returned_bom_items)
self.assertIn(bom_item_line_consumable.pk, returned_bom_items)
self.assertNotIn(bom_item_plain.pk, returned_bom_items)
response = self.get(url, data={'build': build.pk, 'consumable': False})
returned_bom_items = {item['bom_item'] for item in response.data}
self.assertIn(bom_item_plain.pk, returned_bom_items)
self.assertNotIn(bom_item_part_consumable.pk, returned_bom_items)
self.assertNotIn(bom_item_line_consumable.pk, returned_bom_items)
# Check that the serialized 'consumable' field reflects the combined status
response = self.get(
url, data={'build': build.pk, 'bom_item': bom_item_part_consumable.pk}
)
self.assertEqual(len(response.data), 1)
self.assertTrue(response.data[0]['consumable'])
def test_output_options(self):
"""Test output options for the BuildLine endpoint."""
self.run_output_test(
+32
View File
@@ -998,6 +998,38 @@ class AutoAllocationTests(BuildTestBase):
self.assertEqual(self.build.allocated_stock.count(), N - 8)
def test_consumable_via_part(self):
"""A BOM line should be treated as consumable if the underlying part is consumable.
Even though 'bom_item_1' itself is not marked as consumable, marking
'sub_part_1' as consumable should have the same effect as marking the
BOM line itself as consumable.
"""
self.sub_part_1.consumable = True
self.sub_part_1.save()
self.assertFalse(self.bom_item_1.consumable)
self.assertTrue(self.bom_item_1.is_consumable)
# The BuildLine should be treated as fully allocated, without any stock allocated
self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertTrue(self.line_1.is_fully_allocated())
# The build should not consider this line when checking for unallocated lines
unallocated_lines = self.build.unallocated_lines(tracked=False)
self.assertNotIn(self.line_1, unallocated_lines)
# Auto-allocation should skip this line, even though stock exists
self.build.auto_allocate_stock(
interchangeable=True, substitutes=True, optional_items=True
)
self.assertEqual(self.line_1.allocated_quantity(), 0)
self.assertEqual(BuildItem.objects.filter(build_line=self.line_1).count(), 0)
# The other (non-consumable) line should still be allocated as normal
self.assertEqual(self.line_2.allocated_quantity(), 30)
class ExternalBuildTest(InvenTreeAPITestCase):
"""Unit tests for external build order functionality."""
+2
View File
@@ -925,6 +925,8 @@ class PartFilter(FilterSet):
virtual = rest_filters.BooleanFilter()
consumable = rest_filters.BooleanFilter()
tags = common.filters.TagsFilter()
# Created date filters
@@ -0,0 +1,22 @@
# Generated by Django 5.2.15 on 2026-07-02 07:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("part", "0150_part_maximum_stock"),
]
operations = [
migrations.AddField(
model_name="part",
name="consumable",
field=models.BooleanField(
default=False,
help_text="Is this part consumable, such as glue or a fastener?",
verbose_name="Consumable",
),
),
]
+37 -1
View File
@@ -503,6 +503,7 @@ class Part(
active: Is this part active? Parts are deactivated instead of being deleted
locked: This part is locked and cannot be edited
virtual: Is this part "virtual"? e.g. a software product or similar
consumable: Is this part consumable, such as glue or a fastener?
notes: Additional notes field for this part
creation_date: Date that this part was added to the database
creation_user: User who added this part to the database
@@ -1308,6 +1309,12 @@ class Part(
help_text=_('Is this a virtual part, such as a software product or license?'),
)
consumable = models.BooleanField(
default=False,
verbose_name=_('Consumable'),
help_text=_('Is this part consumable, such as glue or a fastener?'),
)
bom_validated = models.BooleanField(
default=False,
verbose_name=_('BOM Validated'),
@@ -1619,7 +1626,7 @@ class Part(
queryset = self.get_bom_items(include_virtual=False)
# Ignore 'consumable' BOM items for this calculation
queryset = queryset.filter(consumable=False)
queryset = queryset.filter(BomItem.consumable_filter(consumable=False))
# Annotate the queryset with the 'can_build' quantity
queryset = part.filters.annotate_bom_item_can_build(queryset)
@@ -4288,6 +4295,35 @@ class BomItem(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
return self.get_item_hash() == self.checksum
@property
def is_consumable(self) -> bool:
"""Return True if this BOM line should be treated as consumable.
This is the case if either:
- The BOM line itself is marked as consumable
- The underlying part is marked as consumable
"""
return self.consumable or self.sub_part.consumable
@staticmethod
def consumable_filter(consumable: bool = True, prefix: str = '') -> Q:
"""Return a Q filter which selects BomItem objects based on "effective" consumable status.
A BomItem is considered "effectively consumable" if either the BOM line itself,
or the underlying part, is marked as consumable.
Arguments:
consumable: If True, return a filter which matches consumable BOM items.
If False, return a filter which matches non-consumable BOM items.
prefix: Optional field lookup prefix, for use against querysets of a
related model (e.g. 'bom_item__' when filtering a BuildLine queryset).
"""
f = Q(**{f'{prefix}consumable': True}) | Q(**{
f'{prefix}sub_part__consumable': True
})
return f if consumable else ~f
def clean(self):
"""Check validity of the BomItem model.
@@ -357,6 +357,7 @@ class PartBriefSerializer(
'testable',
'trackable',
'virtual',
'consumable',
'units',
'pricing_min',
'pricing_max',
@@ -588,6 +589,7 @@ class PartSerializer(
'units',
'variant_of',
'virtual',
'consumable',
'pricing_min',
'pricing_max',
'pricing_updated',
@@ -277,6 +277,52 @@ class BomItemTest(TestCase):
self.assertEqual(assembly.can_build, 20)
# Mark 'c4' as consumable at the *part* level (not the BOM line itself)
c4.consumable = True
c4.save()
bom_item_c4 = BomItem.objects.create(part=assembly, sub_part=c4, quantity=200)
# The raw BomItem field is unset, but the part is marked as consumable
self.assertFalse(bom_item_c4.consumable)
self.assertTrue(bom_item_c4.is_consumable)
# A BomItem which is consumable via its part does not alter the can_build calculation
self.assertEqual(assembly.can_build, 20)
def test_consumable_filter(self):
"""Tests for the BomItem.consumable_filter() helper method."""
assembly = Part.objects.create(
name='Another assembly', description='Made with parts', assembly=True
)
c1 = Part.objects.create(name='D1', description='Not consumable')
c2 = Part.objects.create(name='D2', description='Consumable BOM line')
c3 = Part.objects.create(
name='D3', description='Consumable part', consumable=True
)
bom_item_1 = BomItem.objects.create(part=assembly, sub_part=c1, quantity=1)
bom_item_2 = BomItem.objects.create(
part=assembly, sub_part=c2, quantity=1, consumable=True
)
bom_item_3 = BomItem.objects.create(part=assembly, sub_part=c3, quantity=1)
consumable_items = set(
BomItem.objects.filter(BomItem.consumable_filter(consumable=True))
)
non_consumable_items = set(
BomItem.objects.filter(BomItem.consumable_filter(consumable=False))
)
self.assertIn(bom_item_2, consumable_items)
self.assertIn(bom_item_3, consumable_items)
self.assertNotIn(bom_item_1, consumable_items)
self.assertIn(bom_item_1, non_consumable_items)
self.assertNotIn(bom_item_2, non_consumable_items)
self.assertNotIn(bom_item_3, non_consumable_items)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [BomItem]:
@@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
import structlog
import part.models
from build.events import BuildEvents
from build.models import Build
from build.status_codes import BuildStatus
@@ -56,7 +57,8 @@ class AutoCreateBuildsPlugin(EventMixin, InvenTreePlugin):
"""Process a build order when it is issued."""
# Iterate through all sub-assemblies for the build order
for bom_item in build.part.get_bom_items().filter(
consumable=False, sub_part__assembly=True
part.models.BomItem.consumable_filter(consumable=False),
sub_part__assembly=True,
):
subassembly = bom_item.sub_part
+3
View File
@@ -103,6 +103,9 @@ export function usePartFields({
setVirtual(value);
}
},
consumable: {
default: false
},
locked: {},
active: {},
starred: {
+2
View File
@@ -83,6 +83,7 @@ import {
type IconProps,
IconQrcode,
IconQuestionMark,
IconRecycle,
IconRefresh,
IconRulerMeasure,
IconSearch,
@@ -207,6 +208,7 @@ const icons: InvenTreeIconType = {
purchaseable: IconShoppingCart,
saleable: IconCurrencyDollar,
virtual: IconWorldCode,
consumable: IconRecycle,
inactive: IconX,
part: IconBox,
supplier_part: IconPackageImport,
+12 -1
View File
@@ -549,6 +549,11 @@ export default function PartDetail() {
name: 'virtual',
label: t`Virtual Part`
},
{
type: 'boolean',
name: 'consumable',
label: t`Consumable Part`
},
{
type: 'boolean',
name: 'starred',
@@ -1002,10 +1007,16 @@ export default function PartDetail() {
key='inactive'
/>,
<DetailsBadge
label={t`Virtual Part`}
label={t`Virtual`}
color='cyan.4'
visible={part.virtual}
key='virtual'
/>,
<DetailsBadge
label={t`Consumable`}
color='cyan.4'
visible={part.consumable}
key='consumable'
/>
];
}, [partRequirements, partRequirementsQuery.isFetching, part]);
@@ -1,6 +1,7 @@
import { ActionButton } from '@lib/components/ActionButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import { RowEditAction, RowViewAction } from '@lib/components/RowActions';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -10,7 +11,7 @@ import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import { t } from '@lingui/core/macro';
import { Alert, Group, Paper, Text } from '@mantine/core';
import { Alert, Center, Group, Paper, Text } from '@mantine/core';
import {
IconArrowRight,
IconCircleCheck,
@@ -54,6 +55,16 @@ import { InvenTreeTable } from '../InvenTreeTable';
import RowExpansionIcon from '../RowExpansionIcon';
import { TableHoverCard } from '../TableHoverCard';
/**
* Return true if the given build line record is "effectively consumable" -
* i.e. either the BOM line itself, or the underlying part, is marked as consumable.
*/
function isLineConsumable(record: any): boolean {
return (
!!record?.bom_item_detail?.consumable || !!record?.part_detail?.consumable
);
}
/**
* Render a sub-table of allocated stock against a particular build line.
*
@@ -366,7 +377,12 @@ export default function BuildLineTable({
ordering: 'consumable',
filter: 'consumable',
hidden: hasOutput,
defaultVisible: false
defaultVisible: false,
render: (record: any) => (
<Center>
<YesNoButton value={isLineConsumable(record)} />
</Center>
)
}),
BooleanColumn({
accessor: 'bom_item_detail.allow_variants',
@@ -499,7 +515,7 @@ export default function BuildLineTable({
hidden: !isActive,
minWidth: 125,
render: (record: any) => {
if (record?.bom_item_detail?.consumable) {
if (isLineConsumable(record)) {
return (
<Text
size='sm'
@@ -545,7 +561,7 @@ export default function BuildLineTable({
hidden: !!output?.pk,
minWidth: 125,
render: (record: any) => {
return record?.bom_item_detail?.consumable ? (
return isLineConsumable(record) ? (
<Text
size='sm'
style={{ fontStyle: 'italic' }}
@@ -747,7 +763,7 @@ export default function BuildLineTable({
(record: any): RowAction[] => {
const part = record.part_detail ?? {};
const in_production = build.status == buildStatus.PRODUCTION;
const consumable: boolean = record.bom_item_detail?.consumable ?? false;
const consumable: boolean = isLineConsumable(record);
const trackable: boolean = part?.trackable ?? false;
const hasOutput: boolean = !!output?.pk;
@@ -907,7 +923,7 @@ export default function BuildLineTable({
onClick={() => {
let rows = table.selectedRecords
.filter((r) => r.allocatedQuantity < r.requiredQuantity)
.filter((r) => !r.bom_item_detail?.consumable);
.filter((r) => !isLineConsumable(r));
if (hasOutput) {
rows = rows.filter((r) => r.trackable);
@@ -216,6 +216,10 @@ function partTableColumns(): TableColumn[] {
accessor: 'virtual',
defaultVisible: false
}),
BooleanColumn({
accessor: 'consumable',
defaultVisible: false
}),
LinkColumn({})
];
}
@@ -104,6 +104,12 @@ export function PartTableFilters(): TableFilter[] {
description: t`Filter by parts which are virtual`,
type: 'boolean'
},
{
name: 'consumable',
label: t`Consumable`,
description: t`Filter by parts which are consumable`,
type: 'boolean'
},
{
name: 'is_template',
label: t`Is Template`,