From 6627a8097b6f021fcba1b4730378b39789c48fa6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 17 Feb 2023 10:34:18 +1100 Subject: [PATCH] Stock API filtering fix (#4350) * Improve filtering options for StockItem list - Make use of StockFilter introspection * Remove outdated filter * remove outdated "max_results" parameter * Fix cascade issue for stocklist API * Add relationship filters to the StockItemFilter * Fix filtering by 'status' and 'allocated' * Refactor 'customer' and 'expired' filters * Cleanup * Adds unit test for top-level stock location filtering --- InvenTree/part/api.py | 22 +- InvenTree/stock/api.py | 222 ++++++++------------- InvenTree/stock/test_api.py | 23 +++ InvenTree/templates/InvenTree/index.html | 4 +- InvenTree/templates/js/translated/stock.js | 2 +- 5 files changed, 108 insertions(+), 165 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 079f5d373e..3b11941562 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1342,15 +1342,11 @@ class PartList(APIDownloadMixin, ListCreateAPI): # Does the user wish to filter by category? cat_id = params.get('category', None) - if cat_id is None: - # No category filtering if category is not specified - pass - - else: + if cat_id is not None: # Category has been specified! if isNull(cat_id): # A 'null' category is the top-level category - if cascade is False: + if not cascade: # Do not cascade, only list parts in the top-level category queryset = queryset.filter(category=None) @@ -1393,20 +1389,6 @@ class PartList(APIDownloadMixin, ListCreateAPI): queryset = queryset.filter(pk__in=parts_needed_to_complete_builds) - # Optionally limit the maximum number of returned results - # e.g. for displaying "recent part" list - max_results = params.get('max_results', None) - - if max_results is not None: - try: - max_results = int(max_results) - - if max_results > 0: - queryset = queryset[:max_results] - - except (ValueError): - pass - return queryset filter_backends = [ diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 78bbf891d6..29177eab0b 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -352,6 +352,25 @@ class StockLocationTree(ListAPI): class StockFilter(rest_filters.FilterSet): """FilterSet for StockItem LIST API.""" + class Meta: + """Metaclass options for this filterset""" + + model = StockItem + + # Simple filter filters + fields = [ + 'supplier_part', + 'belongs_to', + 'build', + 'customer', + 'sales_order', + 'purchase_order', + ] + + # Relationship filters + manufactuer = rest_filters.ModelChoiceFilter(label='Manufacturer', queryset=Company.objects.filter(is_manufacturer=True), field_name='manufacturer_part__manufacturer') + supplier = rest_filters.ModelChoiceFilter(label='Supplier', queryset=Company.objects.filter(is_supplier=True), field_name='supplier_part__supplier') + # Part name filters name = rest_filters.CharFilter(label='Part name (case insensitive)', field_name='part__name', lookup_expr='iexact') name_contains = rest_filters.CharFilter(label='Part name contains (case insensitive)', field_name='part__name', lookup_expr='icontains') @@ -369,16 +388,46 @@ class StockFilter(rest_filters.FilterSet): min_stock = rest_filters.NumberFilter(label='Minimum stock', field_name='quantity', lookup_expr='gte') max_stock = rest_filters.NumberFilter(label='Maximum stock', field_name='quantity', lookup_expr='lte') + status = rest_filters.NumberFilter(label='Status Code', method='filter_status') + + def filter_status(self, queryset, name, value): + """Filter by integer status code""" + + return queryset.filter(status=value) + + allocated = rest_filters.BooleanFilter(label='Is Allocated', method='filter_allocated') + + def filter_allocated(self, queryset, name, value): + """Filter by whether or not the stock item is 'allocated'""" + + if str2bool(value): + # Filter StockItem with either build allocations or sales order allocations + return queryset.filter(Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)) + else: + # Filter StockItem without build allocations or sales order allocations + return queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) + + expired = rest_filters.BooleanFilter(label='Expired', method='filter_expired') + + def filter_expired(self, queryset, name, value): + """Filter by whether or not the stock item has expired""" + + if not common.settings.stock_expiry_enabled(): + return queryset + + if str2bool(value): + return queryset.filter(StockItem.EXPIRED_FILTER) + else: + return queryset.exclude(StockItem.EXPIRED_FILTER) + in_stock = rest_filters.BooleanFilter(label='In Stock', method='filter_in_stock') def filter_in_stock(self, queryset, name, value): """Filter by if item is in stock.""" if str2bool(value): - queryset = queryset.filter(StockItem.IN_STOCK_FILTER) + return queryset.filter(StockItem.IN_STOCK_FILTER) else: - queryset = queryset.exclude(StockItem.IN_STOCK_FILTER) - - return queryset + return queryset.exclude(StockItem.IN_STOCK_FILTER) available = rest_filters.BooleanFilter(label='Available', method='filter_available') @@ -389,12 +438,10 @@ class StockFilter(rest_filters.FilterSet): """ if str2bool(value): # The 'quantity' field is greater than the calculated 'allocated' field - queryset = queryset.filter(Q(quantity__gt=F('allocated'))) + return queryset.filter(Q(quantity__gt=F('allocated'))) else: # The 'quantity' field is less than (or equal to) the calculated 'allocated' field - queryset = queryset.filter(Q(quantity__lte=F('allocated'))) - - return queryset + return queryset.filter(Q(quantity__lte=F('allocated'))) batch = rest_filters.CharFilter(label="Batch code filter (case insensitive)", lookup_expr='iexact') @@ -414,11 +461,9 @@ class StockFilter(rest_filters.FilterSet): q = Q(serial=None) | Q(serial='') if str2bool(value): - queryset = queryset.exclude(q) + return queryset.exclude(q) else: - queryset = queryset.filter(q) - - return queryset + return queryset.filter(q) has_batch = rest_filters.BooleanFilter(label='Has batch code', method='filter_has_batch') @@ -427,11 +472,9 @@ class StockFilter(rest_filters.FilterSet): q = Q(batch=None) | Q(batch='') if str2bool(value): - queryset = queryset.exclude(q) + return queryset.exclude(q) else: - queryset = queryset.filter(q) - - return queryset + return queryset.filter(q) tracked = rest_filters.BooleanFilter(label='Tracked', method='filter_tracked') @@ -446,55 +489,45 @@ class StockFilter(rest_filters.FilterSet): q_serial = Q(serial=None) | Q(serial='') if str2bool(value): - queryset = queryset.exclude(q_batch & q_serial) + return queryset.exclude(q_batch & q_serial) else: - queryset = queryset.filter(q_batch & q_serial) - - return queryset + return queryset.filter(q_batch & q_serial) installed = rest_filters.BooleanFilter(label='Installed in other stock item', method='filter_installed') def filter_installed(self, queryset, name, value): """Filter stock items by "belongs_to" field being empty.""" if str2bool(value): - queryset = queryset.exclude(belongs_to=None) + return queryset.exclude(belongs_to=None) else: - queryset = queryset.filter(belongs_to=None) - - return queryset + return queryset.filter(belongs_to=None) sent_to_customer = rest_filters.BooleanFilter(label='Sent to customer', method='filter_sent_to_customer') def filter_sent_to_customer(self, queryset, name, value): """Filter by sent to customer.""" if str2bool(value): - queryset = queryset.exclude(customer=None) + return queryset.exclude(customer=None) else: - queryset = queryset.filter(customer=None) - - return queryset + return queryset.filter(customer=None) depleted = rest_filters.BooleanFilter(label='Depleted', method='filter_depleted') def filter_depleted(self, queryset, name, value): """Filter by depleted items.""" if str2bool(value): - queryset = queryset.filter(quantity__lte=0) + return queryset.filter(quantity__lte=0) else: - queryset = queryset.exclude(quantity__lte=0) - - return queryset + return queryset.exclude(quantity__lte=0) has_purchase_price = rest_filters.BooleanFilter(label='Has purchase price', method='filter_has_purchase_price') def filter_has_purchase_price(self, queryset, name, value): """Filter by having a purchase price.""" if str2bool(value): - queryset = queryset.exclude(purchase_price=None) + return queryset.exclude(purchase_price=None) else: - queryset = queryset.filter(purchase_price=None) - - return queryset + return queryset.filter(purchase_price=None) # Update date filters updated_before = rest_filters.DateFilter(label='Updated before', field_name='updated', lookup_expr='lte') @@ -778,6 +811,13 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): queryset = StockSerializers.StockItemSerializer.annotate_queryset(queryset) + # Also ensure that we pre-fecth all the related items + queryset = queryset.prefetch_related( + 'part', + 'part__category', + 'location' + ) + return queryset def filter_queryset(self, queryset): @@ -786,50 +826,8 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): queryset = super().filter_queryset(queryset) - supplier_part = params.get('supplier_part', None) - - if supplier_part: - queryset = queryset.filter(supplier_part=supplier_part) - - belongs_to = params.get('belongs_to', None) - - if belongs_to: - queryset = queryset.filter(belongs_to=belongs_to) - - build = params.get('build', None) - - if build: - queryset = queryset.filter(build=build) - - sales_order = params.get('sales_order', None) - - if sales_order: - queryset = queryset.filter(sales_order=sales_order) - - purchase_order = params.get('purchase_order', None) - - if purchase_order is not None: - queryset = queryset.filter(purchase_order=purchase_order) - - # Filter stock items which are installed in another (specific) stock item - installed_in = params.get('installed_in', None) - - if installed_in: - # Note: The "installed_in" field is called "belongs_to" - queryset = queryset.filter(belongs_to=installed_in) - if common.settings.stock_expiry_enabled(): - # Filter by 'expired' status - expired = params.get('expired', None) - - if expired is not None: - expired = str2bool(expired) - - if expired: - queryset = queryset.filter(StockItem.EXPIRED_FILTER) - else: - queryset = queryset.exclude(StockItem.EXPIRED_FILTER) # Filter by 'expiry date' expired_date_lte = params.get('expiry_date_lte', None) if expired_date_lte is not None: @@ -846,6 +844,7 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): queryset = queryset.filter(expiry_date__gte=date_gte) except (ValueError, TypeError): pass + # Filter by 'stale' status stale = params.get('stale', None) @@ -865,12 +864,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): else: queryset = queryset.exclude(stale_filter) - # Filter by customer - customer = params.get('customer', None) - - if customer: - queryset = queryset.filter(customer=customer) - # Exclude stock item tree exclude_tree = params.get('exclude_tree', None) @@ -897,19 +890,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): except Exception: pass - # Filter by 'allocated' parts? - allocated = params.get('allocated', None) - - if allocated is not None: - allocated = str2bool(allocated) - - if allocated: - # Filter StockItem with either build allocations or sales order allocations - queryset = queryset.filter(Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)) - else: - # Filter StockItem without build allocations or sales order allocations - queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) - # Exclude StockItems which are already allocated to a particular SalesOrder exclude_so_allocation = params.get('exclude_so_allocation', None) @@ -978,8 +958,9 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): if loc_id is not None: # Filter by 'null' location (i.e. top-level items) - if isNull(loc_id) and not cascade: - queryset = queryset.filter(location=None) + 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 @@ -1015,55 +996,12 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): except (ValueError, BomItem.DoesNotExist): pass - # Filter by StockItem status - status = params.get('status', None) - - if status: - queryset = queryset.filter(status=status) - - # Filter by supplier_part ID - supplier_part_id = params.get('supplier_part', None) - - if supplier_part_id: - queryset = queryset.filter(supplier_part=supplier_part_id) - # Filter by company (either manufacturer or supplier) company = params.get('company', None) if company is not None: queryset = queryset.filter(Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer_part__manufacturer=company)) - # Filter by supplier - supplier = params.get('supplier', None) - - if supplier is not None: - queryset = queryset.filter(supplier_part__supplier=supplier) - - # Filter by manufacturer - manufacturer = params.get('manufacturer', None) - - if manufacturer is not None: - queryset = queryset.filter(supplier_part__manufacturer_part__manufacturer=manufacturer) - - # Optionally, limit the maximum number of returned results - max_results = params.get('max_results', None) - - if max_results is not None: - try: - max_results = int(max_results) - - if max_results > 0: - queryset = queryset[:max_results] - except (ValueError): - pass - - # Also ensure that we pre-fecth all the related items - queryset = queryset.prefetch_related( - 'part', - 'part__category', - 'location' - ) - return queryset filter_backends = [ diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 412944204c..31a8d514fe 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -306,6 +306,29 @@ class StockItemListTest(StockAPITestCase): # Return JSON-ified data return response.data + def test_top_level_filtering(self): + """Test filtering against "top level" stock location""" + + # No filters, should return *all* items + response = self.get(self.list_url, {}, expected_code=200) + self.assertEqual(len(response.data), StockItem.objects.count()) + + # Filter with "cascade=False" (but no location specified) + # Should not result in any actual filtering + response = self.get(self.list_url, {'cascade': False}, expected_code=200) + self.assertEqual(len(response.data), StockItem.objects.count()) + + # Filter with "cascade=False" for the top-level location + response = self.get(self.list_url, {'location': 'null', 'cascade': False}, expected_code=200) + self.assertTrue(len(response.data) < StockItem.objects.count()) + + for result in response.data: + self.assertIsNone(result['location']) + + # Filter with "cascade=True" + response = self.get(self.list_url, {'location': 'null', 'cascade': True}, expected_code=200) + self.assertEqual(len(response.data), StockItem.objects.count()) + def test_get_stock_list(self): """List *all* StockItem objects.""" response = self.get_stock() diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html index 7396a5e133..499aa7cf08 100644 --- a/InvenTree/templates/InvenTree/index.html +++ b/InvenTree/templates/InvenTree/index.html @@ -109,7 +109,7 @@ addHeaderAction('latest-parts', '{% trans "Latest Parts" %}', 'fa-newspaper'); loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", { params: { ordering: "-creation_date", - max_results: {% settings_value "PART_RECENT_COUNT" user=request.user %}, + limit: {% settings_value "PART_RECENT_COUNT" user=request.user %}, }, name: 'latest_parts', }); @@ -147,7 +147,7 @@ loadStockTable($('#table-recently-updated-stock'), { params: { part_detail: true, ordering: "-updated", - max_results: {% settings_value "STOCK_RECENT_COUNT" user=request.user %}, + limit: {% settings_value "STOCK_RECENT_COUNT" user=request.user %}, }, name: 'recently-updated-stock', grouping: false, diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index c5565c4131..db0bb68530 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -2742,7 +2742,7 @@ function loadInstalledInTable(table, options) { table.inventreeTable({ url: '{% url "api-stock-list" %}', queryParams: { - installed_in: options.stock_item, + belongs_to: options.stock_item, part_detail: true, }, formatNoMatches: function() {