2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 22:21:37 +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
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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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=<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):

View File

@@ -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']

View File

@@ -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."""