diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 2559d1312a..9e47af93e9 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 393 +INVENTREE_API_VERSION = 394 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v394 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10438 + - Refactors 'bom_item_detail', 'assembly_detail', 'part_detail', 'build_detail' and 'allocations' param in user BuildLine API endpoint + - Refactors 'part_detail', 'location_detail', 'stock_detail' and 'build_detail' param in user BuildItem API endpoint + v393 -> 2025-09-01 : https://github.com/inventree/InvenTree/pull/10437 - Refactors 'user_detail', 'permission_detail', 'role_detail' param in user GroupList API endpoint diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index 177291b8dc..908e01240d 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -217,15 +217,15 @@ class OutputOptionsMixin: def __init_subclass__(cls, **kwargs): """Automatically attaches OpenAPI schema parameters for its output options.""" super().__init_subclass__(**kwargs) + + if getattr(cls, 'output_options', None) is None: + raise ValueError( + f"Class {cls.__name__} must define 'output_options' attribute" + ) schema_for_view_output_options(cls) def get_serializer(self, *args, **kwargs): """Return serializer instance with output options applied.""" - if not hasattr(self, 'output_options'): - raise AttributeError( - f"Class {self.__class__.__name__} must define 'output_options' attribute" - ) - if self.output_options and hasattr(self, 'request'): params = self.request.query_params kwargs.update(self.output_options.format_params(params)) diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 61c7209834..005e0ab593 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -27,13 +27,19 @@ from build.status_codes import BuildStatus, BuildStatusGroups from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView from InvenTree.api import BulkDeleteMixin, MetadataView +from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( SEARCH_ORDER_FILTER_ALIAS, InvenTreeDateFilter, NumberOrNullFilter, ) from InvenTree.helpers import str2bool -from InvenTree.mixins import CreateAPI, ListCreateAPI, RetrieveUpdateDestroyAPI +from InvenTree.mixins import ( + CreateAPI, + ListCreateAPI, + OutputOptionsMixin, + RetrieveUpdateDestroyAPI, +) from users.models import Owner @@ -530,18 +536,6 @@ class BuildLineMixin: def get_serializer(self, *args, **kwargs): """Return the serializer instance for this endpoint.""" kwargs['context'] = self.get_serializer_context() - - try: - params = self.request.query_params - - kwargs['bom_item_detail'] = str2bool(params.get('bom_item_detail', True)) - kwargs['assembly_detail'] = str2bool(params.get('assembly_detail', True)) - kwargs['part_detail'] = str2bool(params.get('part_detail', True)) - kwargs['build_detail'] = str2bool(params.get('build_detail', False)) - kwargs['allocations'] = str2bool(params.get('allocations', True)) - except AttributeError: - pass - return super().get_serializer(*args, **kwargs) def get_source_build(self) -> Build: @@ -570,12 +564,45 @@ class BuildLineMixin: ) -class BuildLineList(BuildLineMixin, DataExportViewMixin, ListCreateAPI): +class BuildLineOutputOptions(OutputConfiguration): + """Output options for BuildLine endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption( + 'bom_item_detail', + description='Include detailed information about the BOM item linked to this build line.', + default=True, + ), + InvenTreeOutputOption( + 'assembly_detail', + description='Include brief details of the assembly (parent part) related to the BOM item in this build line.', + default=True, + ), + InvenTreeOutputOption( + 'part_detail', + description='Include detailed information about the specific part being built or consumed in this build line.', + default=True, + ), + InvenTreeOutputOption( + 'build_detail', + description='Include detailed information about the associated build order.', + ), + InvenTreeOutputOption( + 'allocations', + description='Include allocation details showing which stock items are allocated to this build line.', + default=True, + ), + ] + + +class BuildLineList( + BuildLineMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI +): """API endpoint for accessing a list of BuildLine objects.""" filterset_class = BuildLineFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS - + output_options = BuildLineOutputOptions ordering_fields = [ 'part', 'allocated', @@ -623,9 +650,11 @@ class BuildLineList(BuildLineMixin, DataExportViewMixin, ListCreateAPI): return source_build -class BuildLineDetail(BuildLineMixin, RetrieveUpdateDestroyAPI): +class BuildLineDetail(BuildLineMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI): """API endpoint for detail view of a BuildLine object.""" + output_options = BuildLineOutputOptions + def get_source_build(self) -> Optional[Build]: """Return the target source location for the BuildLine queryset.""" return None @@ -867,36 +896,32 @@ class BuildItemFilter(FilterSet): ) -class BuildItemList(DataExportViewMixin, BulkDeleteMixin, ListCreateAPI): +class BuildItemOutputOptions(OutputConfiguration): + """Output options for BuildItem endpoint.""" + + OPTIONS = [ + InvenTreeOutputOption('part_detail'), + InvenTreeOutputOption('location_detail'), + InvenTreeOutputOption('stock_detail'), + InvenTreeOutputOption('build_detail'), + ] + + +class BuildItemList( + DataExportViewMixin, OutputOptionsMixin, BulkDeleteMixin, ListCreateAPI +): """API endpoint for accessing a list of BuildItem objects. - GET: Return list of objects - POST: Create a new BuildItem object """ + output_options = BuildItemOutputOptions queryset = BuildItem.objects.all() serializer_class = build.serializers.BuildItemSerializer filterset_class = BuildItemFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS - def get_serializer(self, *args, **kwargs): - """Returns a BuildItemSerializer instance based on the request.""" - try: - params = self.request.query_params - - for key in [ - 'part_detail', - 'location_detail', - 'stock_detail', - 'build_detail', - ]: - if key in params: - kwargs[key] = str2bool(params.get(key, False)) - except AttributeError: - pass - - return super().get_serializer(*args, **kwargs) - def get_queryset(self): """Override the queryset method, to perform custom prefetch.""" queryset = super().get_queryset() diff --git a/src/backend/InvenTree/build/fixtures/build_line.yaml b/src/backend/InvenTree/build/fixtures/build_line.yaml new file mode 100644 index 0000000000..25bb94c5ef --- /dev/null +++ b/src/backend/InvenTree/build/fixtures/build_line.yaml @@ -0,0 +1,18 @@ +# Construct buildline objects + +- model: build.buildline + pk: 2 + fields: + build: 4 + bom_item: 5 + quantity: 20 + consumed: 0 + + +- model: build.buildline + pk: 3 + fields: + build: 5 + bom_item: 3 + quantity: 10 + consumed: 5 diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index e18621d223..46a2321376 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -22,7 +22,7 @@ class TestBuildAPI(InvenTreeAPITestCase): - Tests for BuildItem API """ - fixtures = ['category', 'part', 'location', 'build', 'stock'] + fixtures = ['category', 'part', 'location', 'bom', 'build', 'build_line', 'stock'] roles = ['build.change', 'build.add', 'build.delete'] @@ -109,11 +109,29 @@ class TestBuildAPI(InvenTreeAPITestCase): self.assertIn(2, ids) self.assertNotIn(1, ids) + # test output options + # Test cases: (parameter_name, response_field_name) + test_cases = [ + ('part_detail', 'part_detail'), + ('location_detail', 'location_detail'), + ('stock_detail', 'stock_item_detail'), + ('build_detail', 'build_detail'), + ] + + for param, field in test_cases: + # Test with parameter set to 'true' + response = self.get(url, {param: 'true'}, expected_code=200) + self.assertIn(field, response.data[0]) + + # Test with parameter set to 'false' + response = self.get(url, {param: 'false'}, expected_code=200) + self.assertNotIn(field, response.data[0]) + class BuildAPITest(InvenTreeAPITestCase): """Series of tests for the Build DRF API.""" - fixtures = ['category', 'part', 'location', 'bom', 'build', 'stock'] + fixtures = ['category', 'part', 'location', 'bom', 'build', 'build_line', 'stock'] # Required roles to access Build API endpoints roles = ['build.change', 'build.add'] @@ -1356,6 +1374,36 @@ class BuildLineTests(BuildAPITest): self.assertEqual(n_t + n_f, BuildLine.objects.count()) + def test_output_options(self): + """Test output options for the BuildLine endpoint.""" + url = reverse('api-build-line-detail', kwargs={'pk': 2}) + + # Test cases: (parameter_name, response_field_name) + test_cases = [ + ('bom_item_detail', 'bom_item_detail'), + ('assembly_detail', 'assembly_detail'), + ('part_detail', 'part_detail'), + ('build_detail', 'build_detail'), + ('allocations', 'allocations'), + ] + + for param, field in test_cases: + # Test with parameter set to 'true' + response = self.get(url, {param: 'true'}, expected_code=200) + self.assertIn( + field, + response.data, + f"Field '{field}' should be present when {param}=true", + ) + + # Test with parameter set to 'false' + response = self.get(url, {param: 'false'}, expected_code=200) + self.assertNotIn( + field, + response.data, + f"Field '{field}' should NOT be present when {param}=false", + ) + def test_filter_consumed(self): """Filter for the 'consumed' status.""" # Create a new build order