mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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:
		| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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'] | ||||
|  | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user