diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 7859a8a3db..2d7aefbc5f 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,15 @@ # InvenTree API version -INVENTREE_API_VERSION = 134 +INVENTREE_API_VERSION = 135 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v135 -> 2023-09-19 : https://github.com/inventree/InvenTree/pull/5569 + - Adds location path detail to StockLocation and StockItem API endpoints + - Adds category path detail to PartCategory and Part API endpoints + v134 -> 2023-09-11 : https://github.com/inventree/InvenTree/pull/5525 - Allow "Attachment" list endpoints to be searched by attachment, link and comment fields diff --git a/InvenTree/InvenTree/email.py b/InvenTree/InvenTree/email.py index c2371edfa3..dda090f69a 100644 --- a/InvenTree/InvenTree/email.py +++ b/InvenTree/InvenTree/email.py @@ -45,7 +45,7 @@ def is_email_configured(): configured = False if not testing: # pragma: no cover - logger.warning("DEFAULT_FROM_EMAIL is not configured") + logger.debug("DEFAULT_FROM_EMAIL is not configured") return configured diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index afc1fa43b6..2c1422da56 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -742,6 +742,24 @@ class InvenTreeTree(MPTTModel): """ return self.parentpath + [self] + def get_path(self): + """Return a list of element in the item tree. + + Contains the full path to this item, with each entry containing the following data: + + { + pk: , + name: , + } + """ + + return [ + { + 'pk': item.pk, + 'name': item.name + } for item in self.path + ] + def __str__(self): """String representation of a category is the full path to that category.""" return "{path} - {desc}".format(path=self.pathstring, desc=self.description) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 7553112c34..b69b090d04 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -190,6 +190,18 @@ class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI): class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI): """API endpoint for detail view of a single PartCategory object.""" + def get_serializer(self, *args, **kwargs): + """Add additional context based on query parameters""" + + try: + params = self.request.query_params + + kwargs['path_detail'] = str2bool(params.get('path_detail', False)) + except AttributeError: + pass + + return self.serializer_class(*args, **kwargs) + def update(self, request, *args, **kwargs): """Perform 'update' function and mark this part as 'starred' (or not)""" # Clean up input data @@ -1028,6 +1040,7 @@ class PartMixin: kwargs['parameters'] = str2bool(params.get('parameters', None)) kwargs['category_detail'] = str2bool(params.get('category_detail', False)) + kwargs['path_detail'] = str2bool(params.get('path_detail', False)) except AttributeError: pass diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 3d731f36e3..e6bcb6cb8c 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -55,12 +55,23 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): 'parent', 'part_count', 'pathstring', + 'path', 'starred', 'url', 'structural', 'icon', ] + def __init__(self, *args, **kwargs): + """Optionally add or remove extra fields""" + + path_detail = kwargs.pop('path_detail', False) + + super().__init__(*args, **kwargs) + + if not path_detail: + self.fields.pop('path') + def get_starred(self, category): """Return True if the category is directly "starred" by the current user.""" return category in self.context.get('starred_categories', []) @@ -84,6 +95,12 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer): starred = serializers.SerializerMethodField() + path = serializers.ListField( + child=serializers.DictField(), + source='get_path', + read_only=True, + ) + class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for PartCategory tree.""" @@ -481,6 +498,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize 'barcode_hash', 'category', 'category_detail', + 'category_path', 'component', 'default_expiry', 'default_location', @@ -550,6 +568,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize parameters = kwargs.pop('parameters', False) create = kwargs.pop('create', False) pricing = kwargs.pop('pricing', True) + path_detail = kwargs.pop('path_detail', False) super().__init__(*args, **kwargs) @@ -559,6 +578,9 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize if not parameters: self.fields.pop('parameters') + if not path_detail: + self.fields.pop('category_path') + if not create: # These fields are only used for the LIST API endpoint for f in self.skip_create_fields()[1:]: @@ -670,6 +692,12 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize # Extra detail for the category category_detail = CategorySerializer(source='category', many=False, read_only=True) + category_path = serializers.ListField( + child=serializers.DictField(), + source='category.get_path', + read_only=True, + ) + # Annotated fields allocated_to_build_orders = serializers.FloatField(read_only=True) allocated_to_sales_orders = serializers.FloatField(read_only=True) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 84af79e941..8d44ab0e93 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -441,6 +441,41 @@ class PartCategoryAPITest(InvenTreeAPITestCase): part.refresh_from_db() self.assertEqual(part.category.pk, non_structural_category.pk) + def test_path_detail(self): + """Test path_detail information""" + + url = reverse('api-part-category-detail', kwargs={'pk': 5}) + + # First, request without path detail + response = self.get( + url, + { + 'path_detail': False, + }, + expected_code=200 + ) + + # Check that the path detail information is not included + self.assertFalse('path' in response.data.keys()) + + # Now, request *with* path detail + response = self.get( + url, + { + 'path_detail': True, + }, + expected_code=200 + ) + + self.assertTrue('path' in response.data.keys()) + + path = response.data['path'] + + self.assertEqual(len(path), 3) + self.assertEqual(path[0]['name'], 'Electronics') + self.assertEqual(path[1]['name'], 'IC') + self.assertEqual(path[2]['name'], 'MCU') + class PartOptionsAPITest(InvenTreeAPITestCase): """Tests for the various OPTIONS endpoints in the /part/ API. @@ -1647,6 +1682,20 @@ class PartDetailTests(PartAPITestBase): self.assertEqual(data['in_stock'], 9000) self.assertEqual(data['unallocated_stock'], 9000) + def test_path_detail(self): + """Check that path_detail can be requested against the serializer""" + + response = self.get( + reverse('api-part-detail', kwargs={'pk': 1}), + { + 'path_detail': True, + }, + expected_code=200, + ) + + self.assertIn('category_path', response.data) + self.assertEqual(len(response.data['category_path']), 2) + class PartListTests(PartAPITestBase): """Unit tests for the Part List API endpoint""" diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 519735a98f..d02cc1c056 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -76,11 +76,18 @@ class StockDetail(RetrieveUpdateDestroyAPI): def get_serializer(self, *args, **kwargs): """Set context before returning serializer.""" - kwargs['part_detail'] = True - kwargs['location_detail'] = True - kwargs['supplier_part_detail'] = True kwargs['context'] = self.get_serializer_context() + try: + params = self.request.query_params + + kwargs['part_detail'] = str2bool(params.get('part_detail', True)) + kwargs['location_detail'] = str2bool(params.get('location_detail', True)) + kwargs['supplier_part_detail'] = str2bool(params.get('supplier_part_detail', True)) + kwargs['path_detail'] = str2bool(params.get('path_detail', False)) + except AttributeError: + pass + return self.serializer_class(*args, **kwargs) @@ -1343,6 +1350,20 @@ class LocationDetail(CustomRetrieveUpdateDestroyAPI): queryset = StockLocation.objects.all() serializer_class = StockSerializers.LocationSerializer + def get_serializer(self, *args, **kwargs): + """Add extra context to serializer based on provided query parameters""" + + try: + params = self.request.query_params + + kwargs['path_detail'] = str2bool(params.get('path_detail', False)) + except AttributeError: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + def get_queryset(self, *args, **kwargs): """Return annotated queryset for the StockLocationList endpoint""" diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index cc6822750e..c6b3676e59 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -145,6 +145,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'link', 'location', 'location_detail', + 'location_path', 'notes', 'owner', 'packaging', @@ -205,6 +206,12 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): label=_("Part"), ) + location_path = serializers.ListField( + child=serializers.DictField(), + source='location.get_path', + read_only=True, + ) + """ Field used when creating a stock item """ @@ -329,6 +336,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): location_detail = kwargs.pop('location_detail', False) supplier_part_detail = kwargs.pop('supplier_part_detail', False) tests = kwargs.pop('tests', False) + path_detail = kwargs.pop('path_detail', False) super(StockItemSerializer, self).__init__(*args, **kwargs) @@ -344,6 +352,9 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): if not tests: self.fields.pop('tests') + if not path_detail: + self.fields.pop('location_path') + class SerializeStockItemSerializer(serializers.Serializer): """A DRF serializer for "serializing" a StockItem. @@ -768,6 +779,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'description', 'parent', 'pathstring', + 'path', 'items', 'owner', 'icon', @@ -781,6 +793,16 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): 'barcode_hash', ] + def __init__(self, *args, **kwargs): + """Optionally add or remove extra fields""" + + path_detail = kwargs.pop('path_detail', False) + + super().__init__(*args, **kwargs) + + if not path_detail: + self.fields.pop('path') + @staticmethod def annotate_queryset(queryset): """Annotate extra information to the queryset""" @@ -800,6 +822,12 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer): tags = TagListSerializerField(required=False) + path = serializers.ListField( + child=serializers.DictField(), + source='get_path', + read_only=True, + ) + class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): """Serializer for StockItemAttachment model."""