diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 578f5e56f0..ecbce4152a 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 388 +INVENTREE_API_VERSION = 389 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v389 -> 2025-08-27 : https://github.com/inventree/InvenTree/pull/10214 + - Adds "output" filter to the BuildItem API endpoint + - Removes undocumented 'output' query parameter handling + v388 -> 2025-08-23 : https://github.com/inventree/InvenTree/pull/10213 - Disable paging on PurchaseOrderReceive call diff --git a/src/backend/InvenTree/InvenTree/filters.py b/src/backend/InvenTree/InvenTree/filters.py index 30745679a0..423d9347eb 100644 --- a/src/backend/InvenTree/InvenTree/filters.py +++ b/src/backend/InvenTree/InvenTree/filters.py @@ -159,6 +159,38 @@ class InvenTreeOrderingFilter(filters.OrderingFilter): return ordering +class NumberOrNullFilter(rest_filters.NumberFilter): + """Custom NumberFilter that allows filtering by numeric values or the literal string "null". + + This allows matching either numeric values or NULL values in the database. + + Example Usage: + ?my_field=20 → filters rows where my_field=20 + ?my_field=null → filters rows where my_field IS NULL + """ + + def filter(self, qs, value): + """Return queryset filtered by value or NULL if 'null' is passed.""" + if value == 'null': + return qs.filter(**{self.field_name: None}) + return super().filter(qs, value) + + @property + def field(self): + """Allow 'null' as valid input in filter parameters.""" + field = super().field + original_clean = field.clean + + def custom_clean(val): + """Custom clean function for filter input values.""" + if InvenTree.helpers.isNull(val) and val is not None: + return 'null' + return original_clean(val) + + field.clean = custom_clean + return field + + SEARCH_ORDER_FILTER = [ rest_filters.DjangoFilterBackend, InvenTreeSearchFilter, diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 1ddeff9fda..4e110ef004 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -23,8 +23,12 @@ from build.status_codes import BuildStatus, BuildStatusGroups from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView from InvenTree.api import BulkDeleteMixin, MetadataView -from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter -from InvenTree.helpers import isNull, str2bool +from InvenTree.filters import ( + SEARCH_ORDER_FILTER_ALIAS, + InvenTreeDateFilter, + NumberOrNullFilter, +) +from InvenTree.helpers import str2bool from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI from users.models import Owner @@ -850,6 +854,14 @@ class BuildItemFilter(rest_filters.FilterSet): locations = location.get_descendants(include_self=True) return queryset.filter(stock_item__location__in=locations) + output = NumberOrNullFilter( + field_name='install_into', + label=_('Output'), + help_text=_( + "Filter by output stock item ID. Use 'null' to find uninstalled build items." + ), + ) + class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): """API endpoint for accessing a list of BuildItem objects. @@ -888,6 +900,11 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): queryset = queryset.select_related( 'build_line', 'build_line__build', + 'build_line__build__part', + 'build_line__build__responsible', + 'build_line__build__issued_by', + 'build_line__build__project_code', + 'build_line__build__part__pricing_data', 'build_line__bom_item', 'build_line__bom_item__part', 'build_line__bom_item__sub_part', @@ -899,24 +916,7 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): 'stock_item__supplier_part__supplier', 'stock_item__supplier_part__manufacturer_part', 'stock_item__supplier_part__manufacturer_part__manufacturer', - ).prefetch_related('stock_item__location__tags') - - return queryset - - def filter_queryset(self, queryset): - """Custom query filtering for the BuildItem list.""" - queryset = super().filter_queryset(queryset) - - params = self.request.query_params - - # Filter by output target - output = params.get('output', None) - - if output: - if isNull(output): - queryset = queryset.filter(install_into=None) - else: - queryset = queryset.filter(install_into=output) + ).prefetch_related('stock_item__location__tags', 'stock_item__tags') return queryset diff --git a/src/backend/InvenTree/build/fixtures/build.yaml b/src/backend/InvenTree/build/fixtures/build.yaml index a84a043d12..82a52dd413 100644 --- a/src/backend/InvenTree/build/fixtures/build.yaml +++ b/src/backend/InvenTree/build/fixtures/build.yaml @@ -80,3 +80,16 @@ level: 0 lft: 1 rght: 2 + +- model: build.builditem + pk: 1 + fields: + quantity: 3 + stock_item: 1 + +- model: build.builditem + pk: 2 + fields: + quantity: 10 + stock_item: 11 + install_into: 100 diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 511ba7a3de..d81f797693 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -21,7 +21,7 @@ class TestBuildAPI(InvenTreeAPITestCase): - Tests for BuildItem API """ - fixtures = ['category', 'part', 'location', 'build'] + fixtures = ['category', 'part', 'location', 'build', 'stock'] roles = ['build.change', 'build.add', 'build.delete'] @@ -83,15 +83,30 @@ class TestBuildAPI(InvenTreeAPITestCase): self.assertEqual(item['issued_by'], self.user.pk) def test_get_build_item_list(self): - """Test that we can retrieve list of BuildItem objects.""" + """Test retrieving BuildItem list and applying filters like 'part' and 'output'.""" url = reverse('api-build-item-list') + # Retrieve the full list of BuildItem objects response = self.get(url, expected_code=200) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) - # Test again, filtering by park ID + # Filter by part ID (only items for part ID=1 expected) response = self.get(url, {'part': '1'}, expected_code=200) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + # Filter: output=null (install_into=None) + response = self.get(url, {'output': 'null'}, expected_code=200) + ids = [item['pk'] for item in response.data] + self.assertIn(1, ids) + self.assertNotIn(2, ids) + + # Filter: output= (install_into specific ID) + response = self.get(url, {'output': 100}, expected_code=200) + ids = [item['pk'] for item in response.data] + self.assertIn(2, ids) + self.assertNotIn(1, ids) class BuildAPITest(InvenTreeAPITestCase): diff --git a/src/backend/InvenTree/build/tests.py b/src/backend/InvenTree/build/tests.py index 2d94cb4c13..9eb8e379d6 100644 --- a/src/backend/InvenTree/build/tests.py +++ b/src/backend/InvenTree/build/tests.py @@ -15,7 +15,7 @@ from .models import Build class BuildTestSimple(InvenTreeTestCase): """Basic set of tests for the BuildOrder model functionality.""" - fixtures = ['category', 'part', 'location', 'build'] + fixtures = ['category', 'part', 'location', 'build', 'stock'] roles = ['build.change', 'build.add', 'build.delete'] diff --git a/src/backend/InvenTree/plugin/samples/integration/test_validation_sample.py b/src/backend/InvenTree/plugin/samples/integration/test_validation_sample.py index 27e7ab9ce4..b0883344db 100644 --- a/src/backend/InvenTree/plugin/samples/integration/test_validation_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/test_validation_sample.py @@ -12,7 +12,7 @@ from plugin.registry import registry class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase): """Tests for the SampleValidatonPlugin class.""" - fixtures = ['part', 'category', 'location', 'build'] + fixtures = ['part', 'category', 'location', 'build', 'stock'] def setUp(self): """Set up the test environment."""