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:
Oliver 2023-02-17 10:34:18 +11:00 committed by GitHub
parent b02ce1f01e
commit 6627a8097b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 108 additions and 165 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {