mirror of
https://github.com/inventree/InvenTree.git
synced 2025-10-03 15:52:51 +00:00
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 <code@mjmair.com>
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v391 -> 2025-09-06 : https://github.com/inventree/InvenTree/pull/10279
|
||||||
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint
|
- Refactors 'exclude_tree', 'cascade', and 'location' filters in StockList API endpoint
|
||||||
|
|
||||||
|
@@ -212,3 +212,32 @@ class InvenTreeNotesField(models.TextField):
|
|||||||
kwargs['null'] = True
|
kwargs['null'] = True
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
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
|
||||||
|
@@ -7,12 +7,13 @@ from rest_framework.response import Response
|
|||||||
|
|
||||||
import data_exporter.mixins
|
import data_exporter.mixins
|
||||||
import importer.mixins
|
import importer.mixins
|
||||||
from InvenTree.fields import InvenTreeNotesField
|
from InvenTree.fields import InvenTreeNotesField, OutputConfiguration
|
||||||
from InvenTree.helpers import (
|
from InvenTree.helpers import (
|
||||||
clean_markdown,
|
clean_markdown,
|
||||||
remove_non_printable_characters,
|
remove_non_printable_characters,
|
||||||
strip_html_tags,
|
strip_html_tags,
|
||||||
)
|
)
|
||||||
|
from InvenTree.schema import schema_for_view_output_options
|
||||||
|
|
||||||
|
|
||||||
class CleanMixin:
|
class CleanMixin:
|
||||||
@@ -206,3 +207,27 @@ class DataImportExportSerializerMixin(
|
|||||||
importer.mixins.DataImportSerializerMixin,
|
importer.mixins.DataImportSerializerMixin,
|
||||||
):
|
):
|
||||||
"""Mixin class for adding data import/export functionality to a DRF serializer."""
|
"""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)
|
||||||
|
@@ -9,7 +9,13 @@ from drf_spectacular.contrib.django_oauth_toolkit import DjangoOAuthToolkitSchem
|
|||||||
from drf_spectacular.drainage import warn
|
from drf_spectacular.drainage import warn
|
||||||
from drf_spectacular.openapi import AutoSchema
|
from drf_spectacular.openapi import AutoSchema
|
||||||
from drf_spectacular.plumbing import ComponentRegistry
|
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 rest_framework.pagination import LimitOffsetPagination
|
||||||
|
|
||||||
from InvenTree.permissions import OASTokenMixin
|
from InvenTree.permissions import OASTokenMixin
|
||||||
@@ -211,3 +217,29 @@ def postprocess_print_stats(result, generator, request, public):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return result
|
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
|
||||||
|
@@ -10,9 +10,10 @@ from django_filters.rest_framework.filterset import FilterSet
|
|||||||
import part.models
|
import part.models
|
||||||
from data_exporter.mixins import DataExportViewMixin
|
from data_exporter.mixins import DataExportViewMixin
|
||||||
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
|
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.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Address,
|
Address,
|
||||||
@@ -325,6 +326,33 @@ class SupplierPartFilter(FilterSet):
|
|||||||
return queryset.exclude(in_stock__gt=0)
|
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:
|
class SupplierPartMixin:
|
||||||
"""Mixin class for SupplierPart API endpoints."""
|
"""Mixin class for SupplierPart API endpoints."""
|
||||||
|
|
||||||
@@ -342,27 +370,9 @@ class SupplierPartMixin:
|
|||||||
|
|
||||||
return queryset
|
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(
|
class SupplierPartList(
|
||||||
DataExportViewMixin, SupplierPartMixin, ListCreateDestroyAPIView
|
DataExportViewMixin, SupplierPartMixin, OutputOptionsMixin, ListCreateDestroyAPIView
|
||||||
):
|
):
|
||||||
"""API endpoint for list view of SupplierPart object.
|
"""API endpoint for list view of SupplierPart object.
|
||||||
|
|
||||||
@@ -373,6 +383,7 @@ class SupplierPartList(
|
|||||||
filterset_class = SupplierPartFilter
|
filterset_class = SupplierPartFilter
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
|
output_options = SupplierPartOutputOptions
|
||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'SKU',
|
'SKU',
|
||||||
@@ -410,7 +421,9 @@ class SupplierPartList(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartDetail(SupplierPartMixin, RetrieveUpdateDestroyAPI):
|
class SupplierPartDetail(
|
||||||
|
SupplierPartMixin, OutputOptionsMixin, RetrieveUpdateDestroyAPI
|
||||||
|
):
|
||||||
"""API endpoint for detail view of SupplierPart object.
|
"""API endpoint for detail view of SupplierPart object.
|
||||||
|
|
||||||
- GET: Retrieve detail view
|
- GET: Retrieve detail view
|
||||||
@@ -418,6 +431,8 @@ class SupplierPartDetail(SupplierPartMixin, RetrieveUpdateDestroyAPI):
|
|||||||
- DELETE: Delete object
|
- DELETE: Delete object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
output_options = SupplierPartOutputOptions
|
||||||
|
|
||||||
|
|
||||||
class SupplierPriceBreakFilter(FilterSet):
|
class SupplierPriceBreakFilter(FilterSet):
|
||||||
"""Custom API filters for the SupplierPriceBreak list endpoint."""
|
"""Custom API filters for the SupplierPriceBreak list endpoint."""
|
||||||
|
@@ -565,6 +565,29 @@ class SupplierPartTest(InvenTreeAPITestCase):
|
|||||||
response = self.get(url, {'part': pk}, expected_code=200)
|
response = self.get(url, {'part': pk}, expected_code=200)
|
||||||
self.assertEqual(len(response.data), n)
|
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):
|
def test_available(self):
|
||||||
"""Tests for updating the 'available' field."""
|
"""Tests for updating the 'available' field."""
|
||||||
url = reverse('api-supplier-part-list')
|
url = reverse('api-supplier-part-list')
|
||||||
|
Reference in New Issue
Block a user