mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -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 = [ | ||||
|   | ||||
| @@ -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,7 +958,8 @@ 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: | ||||
|             if isNull(loc_id): | ||||
|                 if not cascade: | ||||
|                     queryset = queryset.filter(location=None) | ||||
|             else: | ||||
|                 try: | ||||
| @@ -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 = [ | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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() { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user