From 65531f7611f4d5f20107e56628f4abcb04374380 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Nov 2023 18:25:52 +1100 Subject: [PATCH] API bug fix: Distinct query (#5940) * Fix "company" filter for StockList - distinct() was in the wrong spot - Added a new unit test to cover this * Update stocklist API filter - Move custom filtering into FilterSet class - Exposes available filters to API documentation - Improved readability / field validation * Further improvements for StockList API * For for order extra line item serializer - 'title' is not a valid field --- InvenTree/order/api.py | 5 +- InvenTree/stock/api.py | 163 ++++++++++++++++++------------------ InvenTree/stock/test_api.py | 9 +- 3 files changed, 91 insertions(+), 86 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 4e6b859aa2..fd7bade3d7 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -66,17 +66,16 @@ class GeneralExtraLineList(APIDownloadMixin): filter_backends = SEARCH_ORDER_FILTER ordering_fields = [ - 'title', 'quantity', 'note', 'reference', ] search_fields = [ - 'title', 'quantity', 'note', - 'reference' + 'reference', + 'description', ] filterset_fields = [ diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 52632dbcd3..05d8cf4e89 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -629,10 +629,92 @@ class StockFilter(rest_filters.FilterSet): parent__in=ancestor.get_descendants(include_self=True) ) + category = rest_filters.ModelChoiceFilter( + label=_('Category'), + queryset=PartCategory.objects.all(), + method='filter_category' + ) + + def filter_category(self, queryset, name, category): + """Filter based on part category""" + + child_categories = category.get_descendants(include_self=True) + + return queryset.filter( + part__category__in=child_categories, + ) + + bom_item = rest_filters.ModelChoiceFilter( + label=_('BOM Item'), + queryset=BomItem.objects.all(), + method='filter_bom_item' + ) + + def filter_bom_item(self, queryset, name, bom_item): + """Filter based on BOM item""" + + return queryset.filter(bom_item.get_stock_filter()) + + part_tree = rest_filters.ModelChoiceFilter( + label=_('Part Tree'), + queryset=Part.objects.all(), + method='filter_part_tree' + ) + + def filter_part_tree(self, queryset, name, part_tree): + """Filter based on part tree""" + return queryset.filter( + part__tree_id=part_tree.tree_id + ) + + company = rest_filters.ModelChoiceFilter( + label=_('Company'), + queryset=Company.objects.all(), + method='filter_company' + ) + + def filter_company(self, queryset, name, company): + """Filter by company (either manufacturer or supplier)""" + return queryset.filter( + Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer_part__manufacturer=company) + ).distinct() + # Update date filters updated_before = rest_filters.DateFilter(label='Updated before', field_name='updated', lookup_expr='lte') updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte') + # Stock "expiry" filters + expiry_date_lte = rest_filters.DateFilter( + label=_("Expiry date before"), + field_name='expiry_date', + lookup_expr='lte', + ) + + expiry_date_gte = rest_filters.DateFilter( + label=_('Expiry date after'), + field_name='expiry_date', + lookup_expr='gte', + ) + + stale = rest_filters.BooleanFilter(label=_('Stale'), method='filter_stale') + + def filter_stale(self, queryset, name, value): + """Filter by stale stock items.""" + + stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS') + + if stale_days <= 0: + # No filtering, does not make sense + return queryset + + stale_date = datetime.now().date() + timedelta(days=stale_days) + stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date) + + if str2bool(value): + return queryset.filter(stale_filter) + else: + return queryset.exclude(stale_filter) + class StockList(APIDownloadMixin, ListCreateDestroyAPIView): """API endpoint for list view of Stock objects. @@ -898,44 +980,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): queryset = super().filter_queryset(queryset) - if common.settings.stock_expiry_enabled(): - - # Filter by 'expiry date' - expired_date_lte = params.get('expiry_date_lte', None) - if expired_date_lte is not None: - try: - date_lte = datetime.fromisoformat(expired_date_lte) - queryset = queryset.filter(expiry_date__lte=date_lte) - except (ValueError, TypeError): - pass - - expiry_date_gte = params.get('expiry_date_gte', None) - if expiry_date_gte is not None: - try: - date_gte = datetime.fromisoformat(expiry_date_gte) - queryset = queryset.filter(expiry_date__gte=date_gte) - except (ValueError, TypeError): - pass - - # Filter by 'stale' status - stale = params.get('stale', None) - - if stale is not None: - stale = str2bool(stale) - - # How many days to account for "staleness"? - stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS') - - if stale_days > 0: - stale_date = datetime.now().date() + timedelta(days=stale_days) - - stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date) - - if stale: - queryset = queryset.filter(stale_filter) - else: - queryset = queryset.exclude(stale_filter) - # Exclude stock item tree exclude_tree = params.get('exclude_tree', None) @@ -950,18 +994,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): except (ValueError, StockItem.DoesNotExist): pass - # Filter by "part tree" - only allow parts within a given variant tree - part_tree = params.get('part_tree', None) - - if part_tree is not None: - try: - part = Part.objects.get(pk=part_tree) - - if part.tree_id is not None: - queryset = queryset.filter(part__tree_id=part.tree_id) - except Exception: - pass - # Exclude StockItems which are already allocated to a particular SalesOrder exclude_so_allocation = params.get('exclude_so_allocation', None) @@ -1032,37 +1064,6 @@ class StockList(APIDownloadMixin, ListCreateDestroyAPIView): except (ValueError, StockLocation.DoesNotExist): pass - # Does the client wish to filter by part category? - cat_id = params.get('category', None) - - if cat_id: - try: - category = PartCategory.objects.get(pk=cat_id) - queryset = queryset.filter(part__category__in=category.getUniqueChildren()) - - except (ValueError, PartCategory.DoesNotExist): - raise ValidationError({"category": "Invalid category id specified"}) - - # Does the client wish to filter by BomItem - bom_item_id = params.get('bom_item', None) - - if bom_item_id is not None: - try: - bom_item = BomItem.objects.get(pk=bom_item_id) - - queryset = queryset.filter(bom_item.get_stock_filter()) - - except (ValueError, BomItem.DoesNotExist): - pass - - # 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).distinct() - ) - return queryset filter_backends = SEARCH_ORDER_FILTER_ALIAS diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 6f6165f68e..3c9dd6931d 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -486,6 +486,11 @@ class StockItemListTest(StockAPITestCase): response = self.get_stock(batch='B123') self.assertEqual(len(response), 1) + def test_filter_by_company(self): + """Test that we can filter stock items by company""" + for cmp in company.models.Company.objects.all(): + self.get_stock(company=cmp.pk) + def test_filter_by_serialized(self): """Filter StockItem by serialized status.""" response = self.get_stock(serialized=1) @@ -740,10 +745,10 @@ class StockItemListTest(StockAPITestCase): def test_query_count(self): """Test that the number of queries required to fetch stock items is reasonable.""" - def get_stock(data): + def get_stock(data, expected_status=200): """Helper function to fetch stock items.""" response = self.client.get(self.list_url, data=data) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, expected_status) return response.data # Create a bunch of StockItem objects