From 4f6256011b9a6556337f5dea26b0f567d5cb772e Mon Sep 17 00:00:00 2001 From: Reza <50555450+Reza98Sh@users.noreply.github.com> Date: Sat, 4 Oct 2025 02:49:00 +0330 Subject: [PATCH] Refactor API endpoint: Stock (5/6) (#10444) * add output options for StockLocation and StockItemTestResult endpoints * add output options tests for StockLocation and StockItemTestResult endpoints * add filters for include_installed and stock_item in StockItemTestResultFilter; update tests for new filter options * update stock test.py * update api_version.py * fix test_installed_tests * Enhance error handling and add tests for stock_item and include_installed filters --------- Co-authored-by: Oliver Co-authored-by: Matthias Mair --- .../InvenTree/InvenTree/api_version.py | 8 +- src/backend/InvenTree/stock/api.py | 193 +++++++++--------- .../InvenTree/stock/fixtures/stock.yaml | 1 + src/backend/InvenTree/stock/test_api.py | 105 +++++++++- src/backend/InvenTree/stock/tests.py | 4 +- 5 files changed, 213 insertions(+), 98 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 0cbd5eadda..b474538cfd 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,18 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 396 +INVENTREE_API_VERSION = 397 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v397 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10444 + - Refactors 'path_detail' param in StockLocation API endpoint + - Refactors 'user_detail' and 'template_detail' params in StockItemTestResult API endpoint + - Refactors 'item_detail' and 'user_detail' params in StockTracking API endpoint + - Refactors 'include_installed' and 'stock_item' params in StockItemTestResult API endpoint + v396 -> 2025-10-01 : https://github.com/inventree/InvenTree/pull/10443 - Refactors 'part_detail', 'manufacturer_detail' and 'pretty' params in Manufacturer API endpoint - Refactors 'manufacturer_part_detail' param in ManufacturerPartParameter API endpoint diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 3c17a1afdd..0702436ed4 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -35,6 +35,7 @@ from InvenTree.api import ( ListCreateDestroyAPIView, MetadataView, ) +from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( ORDER_FILTER_ALIAS, SEARCH_ORDER_FILTER, @@ -48,6 +49,7 @@ from InvenTree.mixins import ( CustomRetrieveUpdateDestroyAPI, ListAPI, ListCreateAPI, + OutputOptionsMixin, RetrieveAPI, RetrieveUpdateDestroyAPI, ) @@ -379,12 +381,6 @@ class StockLocationMixin: def get_serializer(self, *args, **kwargs): """Set context before returning serializer.""" - try: - params = self.request.query_params - kwargs['path_detail'] = str2bool(params.get('path_detail', False)) - except AttributeError: # pragma: no cover - pass - kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) @@ -396,8 +392,23 @@ class StockLocationMixin: return queryset +class StockLocationOutputOptions(OutputConfiguration): + """Output options for StockLocation serializers.""" + + OPTIONS = [ + InvenTreeOutputOption( + description='Include detailed information about the BOM item linked to this build line.', + flag='path_detail', + ) + ] + + class StockLocationList( - DataExportViewMixin, BulkUpdateMixin, StockLocationMixin, ListCreateAPI + DataExportViewMixin, + BulkUpdateMixin, + StockLocationMixin, + OutputOptionsMixin, + ListCreateAPI, ): """API endpoint for list view of StockLocation objects. @@ -407,6 +418,7 @@ class StockLocationList( filterset_class = StockLocationFilter filter_backends = SEARCH_ORDER_FILTER + output_options = StockLocationOutputOptions search_fields = ['name', 'description', 'pathstring', 'tags__name', 'tags__slug'] @@ -415,9 +427,13 @@ class StockLocationList( ordering = ['tree_id', 'lft', 'name'] -class StockLocationDetail(StockLocationMixin, CustomRetrieveUpdateDestroyAPI): +class StockLocationDetail( + StockLocationMixin, OutputOptionsMixin, CustomRetrieveUpdateDestroyAPI +): """API endpoint for detail view of StockLocation object.""" + output_options = StockLocationOutputOptions + def destroy(self, request, *args, **kwargs): """Delete a Stock location instance via the API.""" delete_stock_items = str(request.data.get('delete_stock_items', 0)) == '1' @@ -1017,30 +1033,7 @@ class StockApiMixin: return ctx def get_serializer(self, *args, **kwargs): - """Set context before returning serializer. - - Extra detail may be provided to the serializer via query parameters: - - - part_detail: Include detail about the StockItem's part - - location_detail: Include detail about the StockItem's location - - supplier_part_detail: Include detail about the StockItem's supplier_part - - tests: Include detail about the StockItem's test results - """ - try: - params = self.request.query_params - - kwargs['part_detail'] = str2bool(params.get('part_detail', True)) - - for key in [ - 'path_detail', - 'location_detail', - 'supplier_part_detail', - 'tests', - ]: - kwargs[key] = str2bool(params.get(key, False)) - except AttributeError: # pragma: no cover - pass - + """Set context before returning serializer.""" kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) @@ -1321,24 +1314,27 @@ class StockItemTestResultMixin: def get_serializer(self, *args, **kwargs): """Set context before returning serializer.""" - try: - kwargs['user_detail'] = str2bool( - self.request.query_params.get('user_detail', False) - ) - kwargs['template_detail'] = str2bool( - self.request.query_params.get('template_detail', False) - ) - except Exception: # pragma: no cover - pass - kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) -class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI): +class StockItemTestResultOutputOptions(OutputConfiguration): + """Output options for StockItemTestResult endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption(flag='user_detail'), + InvenTreeOutputOption(flag='template_detail'), + ] + + +class StockItemTestResultDetail( + StockItemTestResultMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): """Detail endpoint for StockItemTestResult.""" + output_options = StockItemTestResultOutputOptions + class StockItemTestResultFilter(FilterSet): """API filter for the StockItemTestResult list.""" @@ -1381,52 +1377,71 @@ class StockItemTestResultFilter(FilterSet): key = generateTestKey(value) return queryset.filter(template__key=key) + include_installed = rest_filters.BooleanFilter( + method='filter_include_installed', + label=_('Include Installed'), + help_text=_( + 'If true, include test results for items installed underneath the given stock item' + ), + ) + + stock_item = rest_filters.NumberFilter( + method='filter_stock_item', + label=_('Stock Item'), + help_text=_('Filter by numeric Stock Item ID'), + ) + + def filter_include_installed(self, queryset, name, value): + """Dummy filter method for 'include_installed'. + + - Ensures 'include_installed' appears in API documentation + - Does NOT actually filter the queryset directly + - The actual logic is handled in filter_stock_item method + """ + return queryset + + def filter_stock_item(self, queryset, name, value): + """Filter for stock_item that also applies include_installed logic.""" + include_installed = str2bool(self.data.get('include_installed', False)) + + try: + item = StockItem.objects.get(pk=value) + + except StockItem.DoesNotExist: + raise ValidationError({ + 'stock_item': _('Stock item with ID {id} does not exist').format( + id=value + ) + }) + + items = [item] + + if include_installed: + # Include items which are installed "underneath" this item + # Note that this function is recursive! + installed_items = item.get_installed_items(cascade=True) + items += list(installed_items) + + return queryset.filter(stock_item__in=items) + class StockItemTestResultList( - BulkCreateMixin, StockItemTestResultMixin, ListCreateDestroyAPIView + BulkCreateMixin, + StockItemTestResultMixin, + OutputOptionsMixin, + ListCreateDestroyAPIView, ): """API endpoint for listing (and creating) a StockItemTestResult object.""" filterset_class = StockItemTestResultFilter filter_backends = SEARCH_ORDER_FILTER + output_options = StockItemTestResultOutputOptions filterset_fields = ['user', 'template', 'result', 'value'] ordering_fields = ['date', 'result'] ordering = 'date' - def filter_queryset(self, queryset): - """Filter by build or stock_item.""" - params = self.request.query_params - - queryset = super().filter_queryset(queryset) - - # Filter by stock item - item = params.get('stock_item', None) - - if item is not None: - try: - item = StockItem.objects.get(pk=item) - - items = [item] - - # Do we wish to also include test results for 'installed' items? - include_installed = str2bool(params.get('include_installed', False)) - - if include_installed: - # Include items which are installed "underneath" this item - # Note that this function is recursive! - installed_items = item.get_installed_items(cascade=True) - - items += list(installed_items) - - queryset = queryset.filter(stock_item__in=items) - - except (ValueError, StockItem.DoesNotExist): # pragma: no cover - pass - - return queryset - def perform_create(self, serializer): """Create a new test result object. @@ -1446,7 +1461,16 @@ class StockTrackingDetail(RetrieveAPI): serializer_class = StockSerializers.StockTrackingSerializer -class StockTrackingList(DataExportViewMixin, ListAPI): +class StockTrackingOutputOptions(OutputConfiguration): + """Output options for StockItemTracking endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption(flag='item_detail'), + InvenTreeOutputOption(flag='user_detail'), + ] + + +class StockTrackingList(DataExportViewMixin, OutputOptionsMixin, ListAPI): """API endpoint for list view of StockItemTracking objects. StockItemTracking objects are read-only @@ -1457,23 +1481,10 @@ class StockTrackingList(DataExportViewMixin, ListAPI): queryset = StockItemTracking.objects.all() serializer_class = StockSerializers.StockTrackingSerializer + output_options = StockTrackingOutputOptions def get_serializer(self, *args, **kwargs): """Set context before returning serializer.""" - try: - kwargs['item_detail'] = str2bool( - self.request.query_params.get('item_detail', False) - ) - except Exception: # pragma: no cover - pass - - try: - kwargs['user_detail'] = str2bool( - self.request.query_params.get('user_detail', False) - ) - except Exception: # pragma: no cover - pass - kwargs['context'] = self.get_serializer_context() return super().get_serializer(*args, **kwargs) diff --git a/src/backend/InvenTree/stock/fixtures/stock.yaml b/src/backend/InvenTree/stock/fixtures/stock.yaml index a9d3abd821..8fd9fd97c0 100644 --- a/src/backend/InvenTree/stock/fixtures/stock.yaml +++ b/src/backend/InvenTree/stock/fixtures/stock.yaml @@ -263,6 +263,7 @@ level: 0 lft: 1 rght: 2 + belongs_to: 105 # Multiple stock items for "Bob" (PK 100) - model: stock.stockitem diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 00171de588..bec2773835 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -263,6 +263,16 @@ class StockLocationTest(StockAPITestCase): child.refresh_from_db() self.assertEqual(child.parent, parent_stock_location) + def test_output_options(self): + """Test output options.""" + url = reverse('api-location-detail', kwargs={'pk': 1}) + + response = self.get(url, {'path_detail': 'true'}, expected_code=200) + self.assertIn('path', response.data) + + response = self.get(url, {'path_detail': 'false'}, expected_code=200) + self.assertNotIn('path', response.data) + def test_stock_location_structural(self): """Test the effectiveness of structural stock locations. @@ -777,18 +787,18 @@ class StockItemListTest(StockAPITestCase): def test_filter_installed(self): """Filter StockItem by installed.""" response = self.get_stock(installed=True) - self.assertEqual(len(response), 0) + self.assertEqual(len(response), 1) response = self.get_stock(installed=False) - self.assertEqual(len(response), 29) # TODO: adjust test dataset (belongs_to) + self.assertEqual(len(response), 28) def test_filter_has_installed(self): """Filter StockItem by has_installed.""" response = self.get_stock(has_installed_items=True) - self.assertEqual(len(response), 0) + self.assertEqual(len(response), 1) response = self.get_stock(has_installed_items=False) - self.assertEqual(len(response), 29) # TODO: adjust test dataset (belongs_to) + self.assertEqual(len(response), 28) def test_filter_has_child_items(self): """Filter StockItem by has_child_items.""" @@ -1881,6 +1891,58 @@ class StockTestResultTest(StockAPITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertGreaterEqual(len(response.data), 4) + def test_stock_item_and_include_installed_filters(self): + """Test stock_item filter with and without include_installed option.""" + url = self.get_url() + + # Filter by stock_item without include_installed + response = self.client.get(url, data={'stock_item': 105}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + initial_count = len(response.data) + self.assertGreaterEqual(initial_count, 4) + + # Verify all results belong to stock_item 105 + for result in response.data: + self.assertEqual(result['stock_item'], 105) + + # Filter by stock_item with include_installed=True + response = self.client.get( + url, data={'stock_item': 105, 'include_installed': True} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + installed_count = len(response.data) + self.assertGreaterEqual(installed_count, initial_count) + + # Filter by stock_item with include_installed=False (explicit) + response = self.client.get( + url, data={'stock_item': 105, 'include_installed': False} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), initial_count) + + # Invalid stock_item ID (does not exist) - should return 400 + response = self.client.get(url, data={'stock_item': 999999}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Non-numeric stock_item ID - should return 400 + response = self.client.get(url, data={'stock_item': 'invalid'}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Filter by another stock_item (522) + response = self.client.get(url, data={'stock_item': 522}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + item_522_count = len(response.data) + self.assertGreaterEqual(item_522_count, 4) + + # Verify all results belong to stock_item 522 + for result in response.data: + self.assertEqual(result['stock_item'], 522) + + # include_installed without stock_item (should be ignored) + response_all = self.client.get(url) + response_with_flag = self.client.get(url, data={'include_installed': True}) + self.assertEqual(len(response_all.data), len(response_with_flag.data)) + def test_post_fail(self): """Test failing posts.""" # Attempt to post a new test result without specifying required data @@ -2129,6 +2191,22 @@ class StockTestResultTest(StockAPITestCase): expected_code=201, ) + def test_output_options(self): + """Test output options for single item retrieval.""" + url = reverse('api-stock-test-result-detail', kwargs={'pk': 1}) + + response = self.get(url, {'user_detail': 'true'}, expected_code=200) + self.assertIn('user_detail', response.data) + + response = self.get(url, {'user_detail': 'false'}, expected_code=200) + self.assertNotIn('user_detail', response.data) + + response = self.get(url, {'template_detail': 'true'}, expected_code=200) + self.assertIn('template_detail', response.data) + + response = self.get(url, {'template_detail': 'false'}, expected_code=200) + self.assertNotIn('template_detail', response.data) + class StockTrackingTest(StockAPITestCase): """Tests for the StockTracking API endpoints.""" @@ -2224,6 +2302,25 @@ class StockTrackingTest(StockAPITestCase): self.assertIn(key, deltas) self.assertIsNotNone(deltas.get(key, None)) + def test_output_options(self): + """Test output options.""" + url = self.get_url() + response = self.client.get( + url, {'item_detail': True, 'user_detail': True, 'limit': 2} + ) + + for item in response.data['results']: + self.assertIn('item_detail', item) + self.assertIn('user_detail', item) + + response = self.client.get( + url, {'item_detail': False, 'user_detail': False, 'limit': 2} + ) + + for item in response.data['results']: + self.assertNotIn('item_detail', item) + self.assertNotIn('user_detail', item) + class StockAssignTest(StockAPITestCase): """Unit tests for the stock assignment API endpoint, where stock items are manually assigned to a customer.""" diff --git a/src/backend/InvenTree/stock/tests.py b/src/backend/InvenTree/stock/tests.py index 6df3dddb1b..d2040cf3e5 100644 --- a/src/backend/InvenTree/stock/tests.py +++ b/src/backend/InvenTree/stock/tests.py @@ -1469,7 +1469,7 @@ class TestResultTest(StockTestBase): # Should return the same number of tests as before tests = item.testResultMap(include_installed=True) - self.assertEqual(len(tests), 3) + self.assertEqual(len(tests), 6) if template := PartTestTemplate.objects.filter( part=item.part, key='somenewtest' @@ -1490,7 +1490,7 @@ class TestResultTest(StockTestBase): ) tests = item.testResultMap(include_installed=True) - self.assertEqual(len(tests), 4) + self.assertEqual(len(tests), 7) self.assertIn('somenewtest', tests) self.assertEqual(sub_item.test_results.count(), 2)