Path detail API (#5569)

* Add "path_detail" to stock location serializer

- Requires ?path_detail=1 in URL query parameters
- Only avaialable on the StockLocation detail URL endpoint (not list, too expensive)

* Implement path_detail option for PartCategory detail API endpoint

* Add "path_detail" option to PartSerializer

* Add optional path_detail to StockItem serializer

* Cleanup

* Increment API version

* Add unit test for Part and PartCategory

* Remove debug statement
This commit is contained in:
Oliver 2023-09-19 17:44:06 +10:00 committed by GitHub
parent 314c93d55c
commit c60efd9a1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 166 additions and 5 deletions

View File

@ -2,11 +2,15 @@
# InvenTree API version # 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 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 v134 -> 2023-09-11 : https://github.com/inventree/InvenTree/pull/5525
- Allow "Attachment" list endpoints to be searched by attachment, link and comment fields - Allow "Attachment" list endpoints to be searched by attachment, link and comment fields

View File

@ -45,7 +45,7 @@ def is_email_configured():
configured = False configured = False
if not testing: # pragma: no cover if not testing: # pragma: no cover
logger.warning("DEFAULT_FROM_EMAIL is not configured") logger.debug("DEFAULT_FROM_EMAIL is not configured")
return configured return configured

View File

@ -742,6 +742,24 @@ class InvenTreeTree(MPTTModel):
""" """
return self.parentpath + [self] 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: <pk>,
name: <name>,
}
"""
return [
{
'pk': item.pk,
'name': item.name
} for item in self.path
]
def __str__(self): def __str__(self):
"""String representation of a category is the full path to that category.""" """String representation of a category is the full path to that category."""
return "{path} - {desc}".format(path=self.pathstring, desc=self.description) return "{path} - {desc}".format(path=self.pathstring, desc=self.description)

View File

@ -190,6 +190,18 @@ class CategoryList(CategoryMixin, APIDownloadMixin, ListCreateAPI):
class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI): class CategoryDetail(CategoryMixin, CustomRetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartCategory object.""" """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): def update(self, request, *args, **kwargs):
"""Perform 'update' function and mark this part as 'starred' (or not)""" """Perform 'update' function and mark this part as 'starred' (or not)"""
# Clean up input data # Clean up input data
@ -1028,6 +1040,7 @@ class PartMixin:
kwargs['parameters'] = str2bool(params.get('parameters', None)) kwargs['parameters'] = str2bool(params.get('parameters', None))
kwargs['category_detail'] = str2bool(params.get('category_detail', False)) kwargs['category_detail'] = str2bool(params.get('category_detail', False))
kwargs['path_detail'] = str2bool(params.get('path_detail', False))
except AttributeError: except AttributeError:
pass pass

View File

@ -55,12 +55,23 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
'parent', 'parent',
'part_count', 'part_count',
'pathstring', 'pathstring',
'path',
'starred', 'starred',
'url', 'url',
'structural', 'structural',
'icon', '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): def get_starred(self, category):
"""Return True if the category is directly "starred" by the current user.""" """Return True if the category is directly "starred" by the current user."""
return category in self.context.get('starred_categories', []) return category in self.context.get('starred_categories', [])
@ -84,6 +95,12 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
starred = serializers.SerializerMethodField() starred = serializers.SerializerMethodField()
path = serializers.ListField(
child=serializers.DictField(),
source='get_path',
read_only=True,
)
class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer): class CategoryTree(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for PartCategory tree.""" """Serializer for PartCategory tree."""
@ -481,6 +498,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
'barcode_hash', 'barcode_hash',
'category', 'category',
'category_detail', 'category_detail',
'category_path',
'component', 'component',
'default_expiry', 'default_expiry',
'default_location', 'default_location',
@ -550,6 +568,7 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
parameters = kwargs.pop('parameters', False) parameters = kwargs.pop('parameters', False)
create = kwargs.pop('create', False) create = kwargs.pop('create', False)
pricing = kwargs.pop('pricing', True) pricing = kwargs.pop('pricing', True)
path_detail = kwargs.pop('path_detail', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -559,6 +578,9 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
if not parameters: if not parameters:
self.fields.pop('parameters') self.fields.pop('parameters')
if not path_detail:
self.fields.pop('category_path')
if not create: if not create:
# These fields are only used for the LIST API endpoint # These fields are only used for the LIST API endpoint
for f in self.skip_create_fields()[1:]: for f in self.skip_create_fields()[1:]:
@ -670,6 +692,12 @@ class PartSerializer(InvenTree.serializers.RemoteImageMixin, InvenTree.serialize
# Extra detail for the category # Extra detail for the category
category_detail = CategorySerializer(source='category', many=False, read_only=True) 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 # Annotated fields
allocated_to_build_orders = serializers.FloatField(read_only=True) allocated_to_build_orders = serializers.FloatField(read_only=True)
allocated_to_sales_orders = serializers.FloatField(read_only=True) allocated_to_sales_orders = serializers.FloatField(read_only=True)

View File

@ -441,6 +441,41 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
part.refresh_from_db() part.refresh_from_db()
self.assertEqual(part.category.pk, non_structural_category.pk) 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): class PartOptionsAPITest(InvenTreeAPITestCase):
"""Tests for the various OPTIONS endpoints in the /part/ API. """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['in_stock'], 9000)
self.assertEqual(data['unallocated_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): class PartListTests(PartAPITestBase):
"""Unit tests for the Part List API endpoint""" """Unit tests for the Part List API endpoint"""

View File

@ -76,11 +76,18 @@ class StockDetail(RetrieveUpdateDestroyAPI):
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer.""" """Set context before returning serializer."""
kwargs['part_detail'] = True
kwargs['location_detail'] = True
kwargs['supplier_part_detail'] = True
kwargs['context'] = self.get_serializer_context() 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) return self.serializer_class(*args, **kwargs)
@ -1343,6 +1350,20 @@ class LocationDetail(CustomRetrieveUpdateDestroyAPI):
queryset = StockLocation.objects.all() queryset = StockLocation.objects.all()
serializer_class = StockSerializers.LocationSerializer 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): def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for the StockLocationList endpoint""" """Return annotated queryset for the StockLocationList endpoint"""

View File

@ -145,6 +145,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
'link', 'link',
'location', 'location',
'location_detail', 'location_detail',
'location_path',
'notes', 'notes',
'owner', 'owner',
'packaging', 'packaging',
@ -205,6 +206,12 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
label=_("Part"), label=_("Part"),
) )
location_path = serializers.ListField(
child=serializers.DictField(),
source='location.get_path',
read_only=True,
)
""" """
Field used when creating a stock item Field used when creating a stock item
""" """
@ -329,6 +336,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
location_detail = kwargs.pop('location_detail', False) location_detail = kwargs.pop('location_detail', False)
supplier_part_detail = kwargs.pop('supplier_part_detail', False) supplier_part_detail = kwargs.pop('supplier_part_detail', False)
tests = kwargs.pop('tests', False) tests = kwargs.pop('tests', False)
path_detail = kwargs.pop('path_detail', False)
super(StockItemSerializer, self).__init__(*args, **kwargs) super(StockItemSerializer, self).__init__(*args, **kwargs)
@ -344,6 +352,9 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
if not tests: if not tests:
self.fields.pop('tests') self.fields.pop('tests')
if not path_detail:
self.fields.pop('location_path')
class SerializeStockItemSerializer(serializers.Serializer): class SerializeStockItemSerializer(serializers.Serializer):
"""A DRF serializer for "serializing" a StockItem. """A DRF serializer for "serializing" a StockItem.
@ -768,6 +779,7 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
'description', 'description',
'parent', 'parent',
'pathstring', 'pathstring',
'path',
'items', 'items',
'owner', 'owner',
'icon', 'icon',
@ -781,6 +793,16 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
'barcode_hash', '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 @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate extra information to the queryset""" """Annotate extra information to the queryset"""
@ -800,6 +822,12 @@ class LocationSerializer(InvenTree.serializers.InvenTreeTagModelSerializer):
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
path = serializers.ListField(
child=serializers.DictField(),
source='get_path',
read_only=True,
)
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
"""Serializer for StockItemAttachment model.""" """Serializer for StockItemAttachment model."""