From 8adfa234bb8a1d829816df6115b0f85e0bf85018 Mon Sep 17 00:00:00 2001 From: Reza <50555450+Reza98Sh@users.noreply.github.com> Date: Thu, 11 Sep 2025 01:55:30 +0330 Subject: [PATCH] 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 * 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] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: Matthias Mair Co-authored-by: Oliver Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../InvenTree/InvenTree/api_version.py | 6 +- .../InvenTree/part/test_notification_stale.py | 4 +- src/backend/InvenTree/stock/api.py | 109 ++++++++++-------- .../InvenTree/stock/fixtures/stock.yaml | 11 +- src/backend/InvenTree/stock/test_api.py | 11 +- 5 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c2cfe4f797..239cc70d06 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 = 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 diff --git a/src/backend/InvenTree/part/test_notification_stale.py b/src/backend/InvenTree/part/test_notification_stale.py index 2c88729a2e..86a4189a42 100644 --- a/src/backend/InvenTree/part/test_notification_stale.py +++ b/src/backend/InvenTree/part/test_notification_stale.py @@ -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() diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 29e83bb7ef..a3aadaa99d 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -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 = { diff --git a/src/backend/InvenTree/stock/fixtures/stock.yaml b/src/backend/InvenTree/stock/fixtures/stock.yaml index 364762fa22..a9d3abd821 100644 --- a/src/backend/InvenTree/stock/fixtures/stock.yaml +++ b/src/backend/InvenTree/stock/fixtures/stock.yaml @@ -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 diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index d5c49551ed..051f555683 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -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."""