From 4681cbbfef59d9ac7b2a6c3dfbe11bc7612d2d80 Mon Sep 17 00:00:00 2001 From: Reza <50555450+Reza98Sh@users.noreply.github.com> Date: Wed, 24 Sep 2025 02:45:28 +0330 Subject: [PATCH] Refactor query parameters handling for SupplierPart and StockList endpoints (#10374) * refactor output options for SupplierPart endpoints * Add output options schema generation and corresponding tests for SupplierPart * Bump API version to 392 and update version history; remove unused parameters in SupplierPartMixin * Update API version to 392 and adjust version history with new release date * Enhance output options handling in SupplierPart API --------- Co-authored-by: Matthias Mair --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/fields.py | 29 ++++++++++ src/backend/InvenTree/InvenTree/mixins.py | 27 ++++++++- src/backend/InvenTree/InvenTree/schema.py | 34 ++++++++++- src/backend/InvenTree/company/api.py | 57 ++++++++++++------- src/backend/InvenTree/company/test_api.py | 23 ++++++++ 6 files changed, 151 insertions(+), 24 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 239cc70d06..67614c8a91 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 391 +INVENTREE_API_VERSION = 392 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v392 -> 2025-09-22 : https://github.com/inventree/InvenTree/pull/10374 + - Refactors 'part_detail', 'supplier_detail', 'manufacturer_detail'and 'pretty' param in SupplierPartList API endpoint + v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279 - Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint diff --git a/src/backend/InvenTree/InvenTree/fields.py b/src/backend/InvenTree/InvenTree/fields.py index 95a04636ed..08bcbf84c4 100644 --- a/src/backend/InvenTree/InvenTree/fields.py +++ b/src/backend/InvenTree/InvenTree/fields.py @@ -212,3 +212,32 @@ class InvenTreeNotesField(models.TextField): kwargs['null'] = True super().__init__(**kwargs) + + +class InvenTreeOutputOption: + """Represents an available output option with description, flag name, and default value.""" + + def __init__(self, description: str, flag: str, default=None): + """Initialize the output option.""" + self.description = description + self.flag = flag + self.default = default + + +class OutputConfiguration: + """Holds all available output options for a view. + + This class is responsible for converting incoming query parameters from an API request + into a dictionary of boolean flags, which can then be applied to serializers. + """ + + OPTIONS: list[InvenTreeOutputOption] + + @classmethod + def format_params(cls, params: dict) -> dict[str, bool]: + """Convert query parameters into a dictionary of output flags with boolean values.""" + result = {} + for option in cls.OPTIONS: + value = params.get(option.flag, option.default) + result[option.flag] = InvenTree.helpers.str2bool(value) + return result diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index b0f22e5dfc..177291b8dc 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -7,12 +7,13 @@ from rest_framework.response import Response import data_exporter.mixins import importer.mixins -from InvenTree.fields import InvenTreeNotesField +from InvenTree.fields import InvenTreeNotesField, OutputConfiguration from InvenTree.helpers import ( clean_markdown, remove_non_printable_characters, strip_html_tags, ) +from InvenTree.schema import schema_for_view_output_options class CleanMixin: @@ -206,3 +207,27 @@ class DataImportExportSerializerMixin( importer.mixins.DataImportSerializerMixin, ): """Mixin class for adding data import/export functionality to a DRF serializer.""" + + +class OutputOptionsMixin: + """Mixin to handle output options for API endpoints.""" + + output_options: OutputConfiguration + + def __init_subclass__(cls, **kwargs): + """Automatically attaches OpenAPI schema parameters for its output options.""" + super().__init_subclass__(**kwargs) + 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)) + + return super().get_serializer(*args, **kwargs) diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index 89f24aad1b..4287a621c0 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -9,7 +9,13 @@ from drf_spectacular.contrib.django_oauth_toolkit import DjangoOAuthToolkitSchem from drf_spectacular.drainage import warn from drf_spectacular.openapi import AutoSchema from drf_spectacular.plumbing import ComponentRegistry -from drf_spectacular.utils import _SchemaType +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, + _SchemaType, + extend_schema, + extend_schema_view, +) from rest_framework.pagination import LimitOffsetPagination from InvenTree.permissions import OASTokenMixin @@ -211,3 +217,29 @@ def postprocess_print_stats(result, generator, request, public): ) return result + + +def schema_for_view_output_options(view_class): + """A class decorator that automatically generates schema parameters for a view. + + It works by introspecting the `output_options` attribute on the view itself. + This decorator reads the `output_options` attribute from the view class, + extracts the `OPTIONS` list from it, and creates an OpenApiParameter for each option. + """ + output_config_class = view_class.output_options + + parameters = [] + for option in output_config_class.OPTIONS: + param = OpenApiParameter( + name=option.flag, + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description=option.description, + default=option.default, + ) + parameters.append(param) + + extended_view = extend_schema_view(get=extend_schema(parameters=parameters))( + view_class + ) + return extended_view diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 00479d69d8..9f03d9f6c6 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -10,9 +10,10 @@ from django_filters.rest_framework.filterset import FilterSet import part.models from data_exporter.mixins import DataExportViewMixin from InvenTree.api import ListCreateDestroyAPIView, MetadataView +from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.helpers import str2bool -from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI +from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin, RetrieveUpdateDestroyAPI from .models import ( Address, @@ -325,6 +326,33 @@ class SupplierPartFilter(FilterSet): return queryset.exclude(in_stock__gt=0) +class SupplierPartOutputOptions(OutputConfiguration): + """Available output options for the SupplierPart endpoints.""" + + OPTIONS = [ + InvenTreeOutputOption( + description='Include detailed information about the linked Part in the response', + flag='part_detail', + default=False, + ), + InvenTreeOutputOption( + description='Include detailed information about the Supplier in the response', + flag='supplier_detail', + default=True, + ), + InvenTreeOutputOption( + description='Include detailed information about the Manufacturer in the response', + flag='manufacturer_detail', + default=False, + ), + InvenTreeOutputOption( + description='Format the output with a more readable (pretty) name', + flag='pretty', + default=False, + ), + ] + + class SupplierPartMixin: """Mixin class for SupplierPart API endpoints.""" @@ -342,27 +370,9 @@ class SupplierPartMixin: return queryset - def get_serializer(self, *args, **kwargs): - """Return serializer instance for this endpoint.""" - # Do we wish to include extra detail? - try: - params = self.request.query_params - kwargs['part_detail'] = str2bool(params.get('part_detail', None)) - kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', True)) - kwargs['manufacturer_detail'] = str2bool( - params.get('manufacturer_detail', None) - ) - kwargs['pretty'] = str2bool(params.get('pretty', None)) - except AttributeError: - pass - - kwargs['context'] = self.get_serializer_context() - - return super().get_serializer(*args, **kwargs) - class SupplierPartList( - DataExportViewMixin, SupplierPartMixin, ListCreateDestroyAPIView + DataExportViewMixin, SupplierPartMixin, OutputOptionsMixin, ListCreateDestroyAPIView ): """API endpoint for list view of SupplierPart object. @@ -373,6 +383,7 @@ class SupplierPartList( filterset_class = SupplierPartFilter filter_backends = SEARCH_ORDER_FILTER_ALIAS + output_options = SupplierPartOutputOptions ordering_fields = [ 'SKU', @@ -410,7 +421,9 @@ class SupplierPartList( ] -class SupplierPartDetail(SupplierPartMixin, RetrieveUpdateDestroyAPI): +class SupplierPartDetail( + SupplierPartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI +): """API endpoint for detail view of SupplierPart object. - GET: Retrieve detail view @@ -418,6 +431,8 @@ class SupplierPartDetail(SupplierPartMixin, RetrieveUpdateDestroyAPI): - DELETE: Delete object """ + output_options = SupplierPartOutputOptions + class SupplierPriceBreakFilter(FilterSet): """Custom API filters for the SupplierPriceBreak list endpoint.""" diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 4383b77583..a0920c393c 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -565,6 +565,29 @@ class SupplierPartTest(InvenTreeAPITestCase): response = self.get(url, {'part': pk}, expected_code=200) self.assertEqual(len(response.data), n) + def test_output_options(self): + """Test the output options for SupplierPart detail.""" + sp = SupplierPart.objects.all().first() + url = reverse('api-supplier-part-detail', kwargs={'pk': sp.pk}) + + response = self.get(url, {'part_detail': 'true'}, expected_code=200) + self.assertIn('part_detail', response.data) + + response = self.get(url, {'supplier_detail': 'true'}, expected_code=200) + self.assertIn('supplier_detail', response.data) + + response = self.get(url, {'supplier_detail': 'false'}, expected_code=200) + self.assertNotIn('supplier_detail', response.data) + + response = self.get(url, {'manufacturer_detail': 'true'}, expected_code=200) + self.assertIn('manufacturer_detail', response.data) + + response = self.get(url, {'pretty': 'true'}, expected_code=200) + self.assertIn('pretty_name', response.data) + + response = self.get(url, {'pretty': 'false'}, expected_code=200) + self.assertNotIn('pretty_name', response.data) + def test_available(self): """Tests for updating the 'available' field.""" url = reverse('api-supplier-part-list')