2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-14 06:31:27 +00:00

Refactor (backend): Improve BuildItemList API filters (#10214)

* add output filter to BuildItemFilter and optimize queryset retrieval

* add NumberOrNullFilter to handle filtering by numeric values or 'null'; update BuildItemFilter to use new filter and add tests for output filtering

* update api_version

* fix(tests): Add missing stock fixture to build tests

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Reza
2025-08-29 02:24:11 +03:30
committed by GitHub
parent 69c115c23e
commit b54122f401
7 changed files with 90 additions and 26 deletions

View File

@@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v388 -> 2025-08-23 : https://github.com/inventree/InvenTree/pull/10213
- Disable paging on PurchaseOrderReceive call - Disable paging on PurchaseOrderReceive call

View File

@@ -159,6 +159,38 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
return ordering 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 = [ SEARCH_ORDER_FILTER = [
rest_filters.DjangoFilterBackend, rest_filters.DjangoFilterBackend,
InvenTreeSearchFilter, InvenTreeSearchFilter,

View File

@@ -23,8 +23,12 @@ from build.status_codes import BuildStatus, BuildStatusGroups
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView from generic.states.api import StatusView
from InvenTree.api import BulkDeleteMixin, MetadataView from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter from InvenTree.filters import (
from InvenTree.helpers import isNull, str2bool SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter,
NumberOrNullFilter,
)
from InvenTree.helpers import str2bool
from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI
from users.models import Owner from users.models import Owner
@@ -850,6 +854,14 @@ class BuildItemFilter(rest_filters.FilterSet):
locations = location.get_descendants(include_self=True) locations = location.get_descendants(include_self=True)
return queryset.filter(stock_item__location__in=locations) 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): class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
"""API endpoint for accessing a list of BuildItem objects. """API endpoint for accessing a list of BuildItem objects.
@@ -888,6 +900,11 @@ class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI):
queryset = queryset.select_related( queryset = queryset.select_related(
'build_line', 'build_line',
'build_line__build', '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',
'build_line__bom_item__part', 'build_line__bom_item__part',
'build_line__bom_item__sub_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__supplier',
'stock_item__supplier_part__manufacturer_part', 'stock_item__supplier_part__manufacturer_part',
'stock_item__supplier_part__manufacturer_part__manufacturer', 'stock_item__supplier_part__manufacturer_part__manufacturer',
).prefetch_related('stock_item__location__tags') ).prefetch_related('stock_item__location__tags', 'stock_item__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)
return queryset return queryset

View File

@@ -80,3 +80,16 @@
level: 0 level: 0
lft: 1 lft: 1
rght: 2 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

View File

@@ -21,7 +21,7 @@ class TestBuildAPI(InvenTreeAPITestCase):
- Tests for BuildItem API - Tests for BuildItem API
""" """
fixtures = ['category', 'part', 'location', 'build'] fixtures = ['category', 'part', 'location', 'build', 'stock']
roles = ['build.change', 'build.add', 'build.delete'] roles = ['build.change', 'build.add', 'build.delete']
@@ -83,15 +83,30 @@ class TestBuildAPI(InvenTreeAPITestCase):
self.assertEqual(item['issued_by'], self.user.pk) self.assertEqual(item['issued_by'], self.user.pk)
def test_get_build_item_list(self): 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') url = reverse('api-build-item-list')
# Retrieve the full list of BuildItem objects
response = self.get(url, expected_code=200) response = self.get(url, expected_code=200)
self.assertEqual(response.status_code, status.HTTP_200_OK) 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) response = self.get(url, {'part': '1'}, expected_code=200)
self.assertEqual(response.status_code, status.HTTP_200_OK) 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=<id> (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): class BuildAPITest(InvenTreeAPITestCase):

View File

@@ -15,7 +15,7 @@ from .models import Build
class BuildTestSimple(InvenTreeTestCase): class BuildTestSimple(InvenTreeTestCase):
"""Basic set of tests for the BuildOrder model functionality.""" """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'] roles = ['build.change', 'build.add', 'build.delete']

View File

@@ -12,7 +12,7 @@ from plugin.registry import registry
class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase): class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
"""Tests for the SampleValidatonPlugin class.""" """Tests for the SampleValidatonPlugin class."""
fixtures = ['part', 'category', 'location', 'build'] fixtures = ['part', 'category', 'location', 'build', 'stock']
def setUp(self): def setUp(self):
"""Set up the test environment.""" """Set up the test environment."""