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
This commit is contained in:
Oliver 2023-11-20 18:25:52 +11:00 committed by GitHub
parent 70a96942c1
commit 65531f7611
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 91 additions and 86 deletions

View File

@ -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 = [

View File

@ -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

View File

@ -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