mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
b02ce1f01e
commit
6627a8097b
@ -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,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 = [
|
||||
|
@ -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() {
|
||||
|
Loading…
Reference in New Issue
Block a user