mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Refactor (backend): Improve BuildItemList API filters (#10279)
* refactor(stock): improve StockList api filters * update PR numver in api_version * Update src/backend/InvenTree/InvenTree/api_version.py Co-authored-by: Matthias Mair <code@mjmair.com> * Fix MySQL test failure caused by self-referential FK constraint in StockItem * Data import fix (#10298) * Data import fix - Improved error handling * Tweak frontend display of errors * chore(deps-dev): bump vite from 6.3.5 to 6.3.6 in /src/frontend (#10297) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.3.6 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Matthias Mair <code@mjmair.com> Co-authored-by: Oliver <oliver.henry.walters@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
		| @@ -1,11 +1,15 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 390 | ||||
| INVENTREE_API_VERSION = 391 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
|  | ||||
| v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279 | ||||
|     - Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint | ||||
|  | ||||
| v390 -> 2025-09-03 : https://github.com/inventree/InvenTree/pull/10257 | ||||
|     - Fixes limitation on adding virtual parts to a SalesOrder | ||||
|     - Additional query filter options for BomItem API endpoint | ||||
|   | ||||
| @@ -174,8 +174,8 @@ class StaleStockNotificationTests(InvenTreeTestCase): | ||||
|     def test_check_stale_stock_no_stale_items(self): | ||||
|         """Test check_stale_stock when no stale items exist.""" | ||||
|         # Clear all existing stock items | ||||
|         stock.models.StockItem.objects.update(parent=None) | ||||
|         stock.models.StockItem.objects.all().delete() | ||||
|  | ||||
|         # Create only future expiry items | ||||
|         today = helpers.current_date() | ||||
|         stock.models.StockItem.objects.create( | ||||
| @@ -194,6 +194,7 @@ class StaleStockNotificationTests(InvenTreeTestCase): | ||||
|     def test_check_stale_stock_with_stale_items(self, mock_offload): | ||||
|         """Test check_stale_stock when stale items exist.""" | ||||
|         # Clear existing stock items | ||||
|         stock.models.StockItem.objects.update(parent=None) | ||||
|         stock.models.StockItem.objects.all().delete() | ||||
|  | ||||
|         self.create_stock_items_with_expiry() | ||||
| @@ -229,6 +230,7 @@ class StaleStockNotificationTests(InvenTreeTestCase): | ||||
|     def test_check_stale_stock_filtering(self): | ||||
|         """Test that check_stale_stock properly filters stock items.""" | ||||
|         # Clear all existing stock items first | ||||
|         stock.models.StockItem.objects.update(parent=None) | ||||
|         stock.models.StockItem.objects.all().delete() | ||||
|  | ||||
|         today = helpers.current_date() | ||||
|   | ||||
| @@ -39,8 +39,9 @@ from InvenTree.filters import ( | ||||
|     SEARCH_ORDER_FILTER, | ||||
|     SEARCH_ORDER_FILTER_ALIAS, | ||||
|     InvenTreeDateFilter, | ||||
|     NumberOrNullFilter, | ||||
| ) | ||||
| from InvenTree.helpers import extract_serial_numbers, generateTestKey, isNull, str2bool | ||||
| from InvenTree.helpers import extract_serial_numbers, generateTestKey, str2bool | ||||
| from InvenTree.mixins import ( | ||||
|     CreateAPI, | ||||
|     CustomRetrieveUpdateDestroyAPI, | ||||
| @@ -933,6 +934,66 @@ class StockFilter(rest_filters.FilterSet): | ||||
|         else: | ||||
|             return queryset.exclude(stale_filter) | ||||
|  | ||||
|     exclude_tree = rest_filters.NumberFilter( | ||||
|         method='filter_exclude_tree', | ||||
|         label=_('Exclude Tree'), | ||||
|         help_text=_( | ||||
|             'Provide a StockItem PK to exclude that item and all its descendants' | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     def filter_exclude_tree(self, queryset, name, value): | ||||
|         """Exclude a StockItem and all of its descendants from the queryset.""" | ||||
|         try: | ||||
|             root = StockItem.objects.get(pk=value) | ||||
|             pks_to_exclude = [ | ||||
|                 item.pk for item in root.get_descendants(include_self=True) | ||||
|             ] | ||||
|             return queryset.exclude(pk__in=pks_to_exclude) | ||||
|         except (ValueError, StockItem.DoesNotExist): | ||||
|             # If the value is invalid or the object doesn't exist, do nothing. | ||||
|             return queryset | ||||
|  | ||||
|     cascade = rest_filters.BooleanFilter( | ||||
|         method='filter_cascade', | ||||
|         label=_('Cascade Locations'), | ||||
|         help_text=_('If true, include items in child locations of the given location'), | ||||
|     ) | ||||
|  | ||||
|     location = NumberOrNullFilter( | ||||
|         method='filter_location', | ||||
|         label=_('Location'), | ||||
|         help_text=_("Filter by numeric Location ID or the literal 'null'"), | ||||
|     ) | ||||
|  | ||||
|     def filter_cascade(self, queryset, name, value): | ||||
|         """Dummy filter method for 'cascade'. | ||||
|  | ||||
|         - Ensures 'cascade' appears in API documentation | ||||
|         - Does NOT actually filter the queryset directly | ||||
|         """ | ||||
|         return queryset | ||||
|  | ||||
|     def filter_location(self, queryset, name, value): | ||||
|         """Filter for location that also applies cascade logic.""" | ||||
|         cascade = str2bool(self.data.get('cascade', True)) | ||||
|  | ||||
|         if value == 'null': | ||||
|             if not cascade: | ||||
|                 return queryset.filter(location=None) | ||||
|             return queryset | ||||
|  | ||||
|         if not cascade: | ||||
|             return queryset.filter(location=value) | ||||
|  | ||||
|         try: | ||||
|             loc_obj = StockLocation.objects.get(pk=value) | ||||
|         except StockLocation.DoesNotExist: | ||||
|             return queryset | ||||
|  | ||||
|         children = loc_obj.getUniqueChildren() | ||||
|         return queryset.filter(location__in=children) | ||||
|  | ||||
|  | ||||
| class StockApiMixin: | ||||
|     """Mixin class for StockItem API endpoints.""" | ||||
| @@ -1191,52 +1252,6 @@ class StockList(DataExportViewMixin, StockApiMixin, ListCreateDestroyAPIView): | ||||
|             headers=self.get_success_headers(serializer.data), | ||||
|         ) | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|         """Custom filtering for the StockItem queryset.""" | ||||
|         params = self.request.query_params | ||||
|  | ||||
|         queryset = super().filter_queryset(queryset) | ||||
|  | ||||
|         # Exclude stock item tree | ||||
|         exclude_tree = params.get('exclude_tree', None) | ||||
|  | ||||
|         if exclude_tree is not None: | ||||
|             try: | ||||
|                 item = StockItem.objects.get(pk=exclude_tree) | ||||
|  | ||||
|                 queryset = queryset.exclude( | ||||
|                     pk__in=[it.pk for it in item.get_descendants(include_self=True)] | ||||
|                 ) | ||||
|  | ||||
|             except (ValueError, StockItem.DoesNotExist):  # pragma: no cover | ||||
|                 pass | ||||
|  | ||||
|         # Does the client wish to filter by stock location? | ||||
|         loc_id = params.get('location', None) | ||||
|  | ||||
|         cascade = str2bool(params.get('cascade', True)) | ||||
|  | ||||
|         if loc_id is not None: | ||||
|             # Filter by 'null' location (i.e. top-level items) | ||||
|             if isNull(loc_id): | ||||
|                 if not cascade: | ||||
|                     queryset = queryset.filter(location=None) | ||||
|             else: | ||||
|                 try: | ||||
|                     # If '?cascade=true' then include items which exist in sub-locations | ||||
|                     if cascade: | ||||
|                         location = StockLocation.objects.get(pk=loc_id) | ||||
|                         queryset = queryset.filter( | ||||
|                             location__in=location.getUniqueChildren() | ||||
|                         ) | ||||
|                     else: | ||||
|                         queryset = queryset.filter(location=loc_id) | ||||
|  | ||||
|                 except (ValueError, StockLocation.DoesNotExist):  # pragma: no cover | ||||
|                     pass | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     filter_backends = SEARCH_ORDER_FILTER_ALIAS | ||||
|  | ||||
|     ordering_field_aliases = { | ||||
|   | ||||
| @@ -274,18 +274,19 @@ | ||||
|     tree_id: 9 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|     rght: 4 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1001 | ||||
|   fields: | ||||
|     part: 100 | ||||
|     parent: 1000 | ||||
|     location: 1 | ||||
|     quantity: 11 | ||||
|     tree_id: 14 | ||||
|     level: 0 | ||||
|     lft: 1 | ||||
|     rght: 2 | ||||
|     tree_id: 9 | ||||
|     level: 1 | ||||
|     lft: 2 | ||||
|     rght: 3 | ||||
|  | ||||
| - model: stock.stockitem | ||||
|   pk: 1002 | ||||
|   | ||||
| @@ -621,6 +621,13 @@ class StockItemListTest(StockAPITestCase): | ||||
|         response = self.get_stock(location=7) | ||||
|         self.assertEqual(len(response), 18) | ||||
|  | ||||
|     def test_filter_by_exclude_tree(self): | ||||
|         """Filter StockItem by excluding a StockItem tree.""" | ||||
|         response = self.get_stock(exclude_tree=1000) | ||||
|         for item in response: | ||||
|             self.assertNotEqual(item['pk'], 1000) | ||||
|             self.assertNotEqual(item['parent'], 1000) | ||||
|  | ||||
|     def test_filter_by_depleted(self): | ||||
|         """Filter StockItem by depleted status.""" | ||||
|         response = self.get_stock(depleted=1) | ||||
| @@ -786,10 +793,10 @@ class StockItemListTest(StockAPITestCase): | ||||
|     def test_filter_has_child_items(self): | ||||
|         """Filter StockItem by has_child_items.""" | ||||
|         response = self.get_stock(has_child_items=True) | ||||
|         self.assertEqual(len(response), 0) | ||||
|         self.assertEqual(len(response), 1) | ||||
|  | ||||
|         response = self.get_stock(has_child_items=False) | ||||
|         self.assertEqual(len(response), 29)  # TODO: adjust test dataset (belongs_to) | ||||
|         self.assertEqual(len(response), 28)  # TODO: adjust test dataset (belongs_to) | ||||
|  | ||||
|     def test_filter_sent_to_customer(self): | ||||
|         """Filter StockItem by sent_to_customer.""" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user