diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 023bf5c451..0abe5313ee 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -52,6 +52,21 @@ def str2bool(text, test=True): return str(text).lower() in ['0', 'n', 'no', 'none', 'f', 'false', 'off', ] +def isNull(text): + """ + Test if a string 'looks' like a null value. + This is useful for querying the API against a null key. + + Args: + text: Input text + + Returns: + True if the text looks like a null value + """ + + return str(text).strip().lower() in ['top', 'null', 'none', 'empty', 'false', '-1'] + + def decimal2string(d): """ Format a Decimal number as a string, diff --git a/InvenTree/InvenTree/static/script/inventree/part.js b/InvenTree/InvenTree/static/script/inventree/part.js index f05c5670da..dceb08dd5f 100644 --- a/InvenTree/InvenTree/static/script/inventree/part.js +++ b/InvenTree/InvenTree/static/script/inventree/part.js @@ -95,6 +95,10 @@ function loadPartTable(table, url, options={}) { query.active = true; } + // Include sub-category search + // TODO - Make this user-configurable! + query.cascade = true; + var columns = [ { field: 'pk', diff --git a/InvenTree/InvenTree/static/script/inventree/stock.js b/InvenTree/InvenTree/static/script/inventree/stock.js index 1495d04cae..2a06808690 100644 --- a/InvenTree/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/InvenTree/static/script/inventree/stock.js @@ -42,6 +42,10 @@ function loadStockTable(table, options) { var params = options.params || {}; + // Enforce 'cascade' option + // TODO - Make this user-configurable? + params.cascade = true; + console.log('load stock table'); table.inventreeTable({ diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 2a96a47fef..55e711ff5f 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -27,7 +27,7 @@ from . import serializers as part_serializers from InvenTree.status_codes import OrderStatus, StockStatus, BuildStatus from InvenTree.views import TreeSerializer -from InvenTree.helpers import str2bool +from InvenTree.helpers import str2bool, isNull class PartCategoryTree(TreeSerializer): @@ -57,6 +57,31 @@ class CategoryList(generics.ListCreateAPIView): permissions.IsAuthenticated, ] + def get_queryset(self): + """ + Custom filtering: + - Allow filtering by "null" parent to retrieve top-level part categories + """ + + cat_id = self.request.query_params.get('parent', None) + + queryset = super().get_queryset() + + if cat_id is not None: + + # Look for top-level categories + if isNull(cat_id): + queryset = queryset.filter(parent=None) + + else: + try: + cat_id = int(cat_id) + queryset = queryset.filter(parent=cat_id) + except ValueError: + pass + + return queryset + filter_backends = [ DjangoFilterBackend, filters.SearchFilter, @@ -64,7 +89,6 @@ class CategoryList(generics.ListCreateAPIView): ] filter_fields = [ - 'parent', ] ordering_fields = [ @@ -219,12 +243,25 @@ class PartList(generics.ListCreateAPIView): # Start with all objects parts_list = Part.objects.all() - if cat_id: - try: - category = PartCategory.objects.get(pk=cat_id) - parts_list = parts_list.filter(category__in=category.getUniqueChildren()) - except PartCategory.DoesNotExist: - pass + cascade = str2bool(self.request.query_params.get('cascade', False)) + + if cat_id is not None: + + if isNull(cat_id): + parts_list = parts_list.filter(category=None) + else: + try: + cat_id = int(cat_id) + category = PartCategory.objects.get(pk=cat_id) + + # If '?cascade=true' then include parts which exist in sub-categories + if cascade: + parts_list = parts_list.filter(category__in=category.getUniqueChildren()) + # Just return parts directly in the requested category + else: + parts_list = parts_list.filter(category=cat_id) + except (ValueError, PartCategory.DoesNotExist): + pass # Ensure that related models are pre-loaded to reduce DB trips parts_list = self.get_serializer_class().setup_eager_loading(parts_list) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index c23298a441..1c7ae6f4ca 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -18,6 +18,8 @@ class CategorySerializer(InvenTreeModelSerializer): url = serializers.CharField(source='get_absolute_url', read_only=True) + parts = serializers.IntegerField(source='item_count', read_only=True) + class Meta: model = PartCategory fields = [ @@ -27,6 +29,7 @@ class CategorySerializer(InvenTreeModelSerializer): 'pathstring', 'url', 'parent', + 'parts', ] diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index f93eb6604d..64ae616720 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -103,7 +103,7 @@ class PartAPITest(APITestCase): If provided, parts are provided for ANY child category (recursive) """ url = reverse('api-part-list') - data = {'category': 1} + data = {'category': 1, 'cascade': True} # Now request to include child categories response = self.client.get(url, data, format='json') diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 86759a90aa..3eb183ce35 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -19,7 +19,7 @@ from .serializers import LocationSerializer from .serializers import StockTrackingSerializer from InvenTree.views import TreeSerializer -from InvenTree.helpers import str2bool +from InvenTree.helpers import str2bool, isNull from InvenTree.status_codes import StockStatus import os @@ -223,9 +223,33 @@ class StockLocationList(generics.ListCreateAPIView): """ queryset = StockLocation.objects.all() - serializer_class = LocationSerializer + def get_queryset(self): + """ + Custom filtering: + - Allow filtering by "null" parent to retrieve top-level stock locations + """ + + queryset = super().get_queryset() + + loc_id = self.request.query_params.get('parent', None) + + if loc_id is not None: + + # Look for top-level locations + if isNull(loc_id): + queryset = queryset.filter(parent=None) + + else: + try: + loc_id = int(loc_id) + queryset = queryset.filter(parent=loc_id) + except ValueError: + pass + + return queryset + permission_classes = [ permissions.IsAuthenticated, ] @@ -237,7 +261,6 @@ class StockLocationList(generics.ListCreateAPIView): ] filter_fields = [ - 'parent', ] search_fields = [ @@ -373,13 +396,24 @@ class StockList(generics.ListCreateAPIView): # Does the client wish to filter by stock location? loc_id = self.request.query_params.get('location', None) - if loc_id: - try: - location = StockLocation.objects.get(pk=loc_id) - stock_list = stock_list.filter(location__in=location.getUniqueChildren()) - - except (ValueError, StockLocation.DoesNotExist): - pass + cascade = str2bool(self.request.query_params.get('cascade', False)) + + if loc_id is not None: + + # Filter by 'null' location (i.e. top-level items) + if isNull(loc_id): + stock_list = stock_list.filter(location=None) + else: + try: + # If '?cascade=true' then include items which exist in sub-locations + if cascade: + location = StockLocation.objects.get(pk=loc_id) + stock_list = stock_list.filter(location__in=location.getUniqueChildren()) + else: + stock_list = stock_list.filter(location=loc_id) + + except (ValueError, StockLocation.DoesNotExist): + pass # Does the client wish to filter by part category? cat_id = self.request.query_params.get('category', None) @@ -511,13 +545,13 @@ stock_endpoints = [ ] location_endpoints = [ - url(r'^$', LocationDetail.as_view(), name='api-location-detail'), + url(r'^(?P\d+)/', LocationDetail.as_view(), name='api-location-detail'), + + url(r'^.*$', StockLocationList.as_view(), name='api-location-list'), ] stock_api_urls = [ - url(r'location/?', StockLocationList.as_view(), name='api-location-list'), - - url(r'location/(?P\d+)/', include(location_endpoints)), + url(r'location/', include(location_endpoints)), # These JSON endpoints have been replaced (for now) with server-side form rendering - 02/06/2019 # url(r'stocktake/?', StockStocktake.as_view(), name='api-stock-stocktake'), diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 14fe043564..f84667e352 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -119,6 +119,8 @@ class LocationSerializer(InvenTreeModelSerializer): url = serializers.CharField(source='get_absolute_url', read_only=True) + items = serializers.IntegerField(source='item_count', read_only=True) + class Meta: model = StockLocation fields = [ @@ -127,7 +129,8 @@ class LocationSerializer(InvenTreeModelSerializer): 'name', 'description', 'parent', - 'pathstring' + 'pathstring', + 'items', ]