From 40b67f5f121ddae851d27d1b711572f208430b6e Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 12 Apr 2026 10:50:29 +1000 Subject: [PATCH] [API] Filter refactor (#11073) * Lazy evaluation of optional serializer fields - Add OptionalField dataclass - Pass serializer class and kwargs separately * Refactor BuildLineSerializer class * Simplify gathering * Refactor BuildSerializer * Refactor other Build serializers * Refactor Part serializers * Refactoring more serializers to use OptionalField * More refactoring * Cleanup for mixin class * Ensure any optional fields we added in are not missed * Fixes * Rehydrate optional fields for metadata * Add TreePathSerializer class * Further improvements: - Handle case where optional field shadows model property - Consider read_only and write_only fields * Adjust unit tests * Fix for "build_relational_field" - Handle case where optional field shadows model relation * Fix case where multiple fields can share same filter * additional unit tests * Bump API version * Remove top-level detection - Request object is only available for the top-level serializer anyway * Cache serializer to prevent multiple __init__ calls * Revert caching change - Breaks search results * Simplify field removal * Adjust unit test * Remove docstring comment which is no longer true * Ensure read-only fields are skipped for data import * Use SAFE_METHODS * Do not convert to lowercase * Updated docstring * Remove FilterableSerializerField mixin - Annotation now performed using OptionalField - Code can be greatly simplified * Ensure all fields are returned when generating schema * Fix order of operations * Add assertion to unit test * fix style * Fix api_version * Remove duplicate API entries * Remove duplicate API entries * Fix formatting in api_version.py * Tweak ManufacturerPart serializer * Revert formatting change --------- Co-authored-by: Matthias Mair --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/metadata.py | 9 + src/backend/InvenTree/InvenTree/mixins.py | 30 +- .../InvenTree/InvenTree/serializers.py | 459 ++++++++++-------- .../InvenTree/InvenTree/test_serializers.py | 132 +---- src/backend/InvenTree/build/serializers.py | 285 ++++++----- src/backend/InvenTree/build/test_api.py | 14 +- src/backend/InvenTree/common/filters.py | 55 ++- src/backend/InvenTree/common/serializers.py | 24 +- src/backend/InvenTree/company/api.py | 1 + src/backend/InvenTree/company/serializers.py | 177 ++++--- src/backend/InvenTree/company/test_api.py | 2 + src/backend/InvenTree/importer/models.py | 9 +- src/backend/InvenTree/order/serializers.py | 412 ++++++++++------ src/backend/InvenTree/part/serializers.py | 318 +++++++----- src/backend/InvenTree/part/test_api.py | 78 ++- src/backend/InvenTree/stock/serializers.py | 171 ++++--- src/backend/InvenTree/users/api.py | 2 +- src/backend/InvenTree/users/serializers.py | 33 +- src/backend/InvenTree/users/test_api.py | 3 + 20 files changed, 1263 insertions(+), 956 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5719f16cb0..1400e67eb4 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 477 +INVENTREE_API_VERSION = 478 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v478 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11073 + - Add OptionalField class for cleaner handling of optional fields in serializers + v477 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11617 - Non-functional refactor, adaptations of descriptions diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index 86cbc91229..4d8451e2db 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -380,6 +380,15 @@ class InvenTreeMetadata(SimpleMetadata): We take the regular DRF metadata and add our own unique flavor """ + from InvenTree.serializers import OptionalField + + if isinstance(field, OptionalField) or issubclass( + field.__class__, OptionalField + ): + # Rehydrate the OptionalField for proper introspection + rehydrated_field = field.serializer_class(**(field.serializer_kwargs or {})) + return self.get_field_info(rehydrated_field) + # Try to add the child property to the dependent field to be used by the super call if self.label_lookup[field] == 'dependent field': field.get_child(raise_exception=True) diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index 5489404b10..7d181fb995 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -6,7 +6,6 @@ from rest_framework import generics, mixins, status from rest_framework.response import Response import data_exporter.mixins -import data_exporter.serializers import importer.mixins from InvenTree.fields import InvenTreeNotesField, OutputConfiguration from InvenTree.helpers import ( @@ -214,20 +213,6 @@ class OutputOptionsMixin: if getattr(cls, 'output_options', None) is not None: schema_for_view_output_options(cls) - def __init__(self) -> None: - """Initialize the mixin. Check that the serializer is compatible.""" - super().__init__() - - # Check that the serializer was defined - if ( - hasattr(self, 'serializer_class') - and isinstance(self.serializer_class, type) - and (not issubclass(self.serializer_class, FilterableSerializerMixin)) - ): - raise Exception( - 'INVE-I2: `OutputOptionsMixin` can only be used with serializers that contain the `FilterableSerializerMixin` mixin' - ) - def get_serializer(self, *args, **kwargs): """Return serializer instance with output options applied.""" request = getattr(self, 'request', None) @@ -241,20 +226,7 @@ class OutputOptionsMixin: context['request'] = request kwargs['context'] = context - serializer = super().get_serializer(*args, **kwargs) - - # Check if the serializer actually can be filtered - makes not much sense to use this mixin without that prerequisite - if isinstance( - serializer, data_exporter.serializers.DataExportOptionsSerializer - ): - # Skip in this instance, special case for determining export options - pass - elif not isinstance(serializer, FilterableSerializerMixin): - raise Exception( - 'INVE-I2: `OutputOptionsMixin` can only be used with serializers that contain the `FilterableSerializerMixin` mixin' - ) - - return serializer + return super().get_serializer(*args, **kwargs) def get_queryset(self): """Return the queryset with output options applied. diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 5338fa714c..b739143b8a 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -2,8 +2,9 @@ from collections import OrderedDict from copy import deepcopy +from dataclasses import dataclass from decimal import Decimal -from typing import Any, Optional +from typing import Optional from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError as DjangoValidationError @@ -21,9 +22,9 @@ from rest_framework.exceptions import ValidationError from rest_framework.fields import empty from rest_framework.mixins import ListModelMixin from rest_framework.permissions import SAFE_METHODS -from rest_framework.serializers import DecimalField +from rest_framework.serializers import DecimalField, Serializer from rest_framework.utils import model_meta -from taggit.serializers import TaggitSerializer, TagListSerializerField +from taggit.serializers import TaggitSerializer import common.models as common_models import InvenTree.ready @@ -33,94 +34,239 @@ from InvenTree.helpers import str2bool from InvenTree.helpers_model import getModelsWithMixin -# region path filtering -class FilterableSerializerField: - """Mixin to mark serializer as filterable. +@dataclass +class OptionalField: + """DataClass used to optionally enable a serializer field. - This needs to be used in conjunction with `enable_filter` on the serializer field! - """ + This is used in conjunction with the `FilterableSerializerMixin` to allow + dynamic inclusion or exclusion of serializer fields at runtime. - is_filterable = None - is_filterable_vals = {} + Adding OptionalField instances to a serializer class is more "efficient" + than directly adding the field (and later removing it), + as the field is never instantiated unless it is required. - # Options for automatic queryset prefetching - prefetch_fields: Optional[list[str]] = None + Additionally, you can specify prefetch fields which will be applied + to the queryset, *only* if the field is included in the final serializer. - def __init__(self, *args, **kwargs): - """Initialize the serializer.""" - self.is_filterable = kwargs.pop('is_filterable', None) - self.is_filterable_vals = kwargs.pop('is_filterable_vals', {}) - self.prefetch_fields = kwargs.pop('prefetch_fields', None) + This allows for optimization of database queries based only on the requested data. - super().__init__(*args, **kwargs) - - -def enable_filter( - func: Any, - default_include: bool = False, - filter_name: Optional[str] = None, - filter_by_query: bool = True, - prefetch_fields: Optional[list[str]] = None, -): - """Decorator for marking a serializer field as filterable. - - This can be customized by passing in arguments. This only works in conjunction with serializer fields or serializers that contain the `FilterableSerializerField` mixin. - - Args: - func: The serializer field to mark as filterable. Will automatically be passed when used as a decorator. - default_include (bool): If True, the field will be included by default unless explicitly excluded. If False, the field will be excluded by default unless explicitly included. - filter_name (str, optional): The name of the filter parameter to use in the URL. If None, the function name of the (decorated) function will be used. - filter_by_query (bool): If True, also look for filter parameters in the request query parameters. - prefetch_fields (list of str, optional): List of related fields to prefetch when this field is included. This can be used to optimize database queries. - - Returns: - The decorated serializer field, marked as filterable. - """ - # Ensure this function can be actually filtered - if not issubclass(func.__class__, FilterableSerializerField): - raise TypeError( - 'INVE-I2: `enable_filter` can only be applied to serializer fields / serializers that contain the `FilterableSerializerField` mixin!' + Example: + class MySerializer(FilterableSerializerMixin, serializers.ModelSerializer): + my_optional_field = OptionalField( + serializer_class=serializers.CharField, + default_include=False, + filter_name='include_my_field', + serializer_kwargs={ + 'help_text': 'This is an optional field', + 'read_only': True, + }, + prefetch_fields=['related_field'], ) - # Mark the function as filterable - func._kwargs['is_filterable'] = True - func._kwargs['is_filterable_vals'] = { - 'default': default_include, - 'filter_name': filter_name if filter_name else func.field_name, - 'filter_by_query': filter_by_query, - } + """ - # Attach queryset prefetching information - func._kwargs['prefetch_fields'] = prefetch_fields - - return func + serializer_class: Serializer + serializer_kwargs: Optional[dict] = None + default_include: bool = False + filter_name: Optional[str] = None + filter_by_query: bool = True + prefetch_fields: Optional[list[str]] = None class FilterableSerializerMixin: """Mixin that enables filtering of marked fields on a serializer. - Use the `enable_filter` decorator to mark serializer fields as filterable. + Use the `OptionalField` helper class to mark serializer fields as filterable. This introduces overhead during initialization, so only use this mixin when necessary. - If you need to mark a serializer as filterable but it does not contain any filterable fields, set `no_filters = True` to avoid getting an exception that protects against over-application of this mixin. """ - _was_filtered = False - no_filters = False - """If True, do not raise an exception if no filterable fields are found.""" - filter_on_query = True - """If True, also look for filter parameters in the request query parameters.""" + optional_filters: dict = None + fields_to_remove: set = None + optional_fields: set = None + filter_on_query: bool = True def __init__(self, *args, **kwargs): """Initialization routine for the serializer. This gathers and applies filters through kwargs.""" - # add list_serializer_class to meta if not present - reduces duplication - if not isinstance(self, FilterableListSerializer) and ( - not hasattr(self.Meta, 'list_serializer_class') - ): - self.Meta.list_serializer_class = FilterableListSerializer + # Extract some useful context information for later use + context = kwargs.get('context', {}) + self.request = context.get('request', None) or getattr(self, 'request', None) + self.request_query_params = ( + dict(getattr(self.request, 'query_params', {})) if self.request else {} + ) + + self.gather_optional_fields(kwargs) - self.gather_filters(kwargs) super().__init__(*args, **kwargs) - self.do_filtering() + + # Ensure any fields we are *not* using are removed + for field_name in self.fields_to_remove: + self.fields.pop(field_name, None) + + def is_exporting(self) -> bool: + """Determine if we are exporting data.""" + return getattr(self, '_exporting_data', False) + + def is_field_included( + self, field_name: str, field: OptionalField, kwargs: dict + ) -> bool: + """Determine at runtime whether an OptionalField should be included. + + Arguments: + field_name: Name of the field + field: The OptionalField instance + kwargs: The kwargs provided to the serializer instance + + Returns: + True if the field should be included, False otherwise. + + Order of operations: + + - If we are generating the schema, always include the field + - If this is a write request (POST, PUT, PATCH) and we are not exporting, always include the field + - If this is a top-level serializer, check the request query parameters for the filter name + - Check the kwargs provided to the serializer instance + - Finally, fall back to the default_include value for the field itself + """ + field_ref = field.filter_name or field_name + + # If we have already found a value for this filter, use it + # This allows multiple optional fields to share the same filter value + cached_value = self.optional_filters.get(field_ref, None) + + if cached_value is not None: + return cached_value + + # First, check kwargs provided to the serializer instance + # We also pop the value to avoid issues with nested serializers + value = kwargs.pop(field_ref, None) + + # We do not want to pop fields while generating the schema + if InvenTree.ready.isGeneratingSchema(): + return True + + if value is not None: + # Cache the value for future reference + self.optional_filters[field_ref] = value + + field_kwargs = field.serializer_kwargs or {} + + # Skip filtering for a write request - all fields should be present for data creation + if method := getattr(self.request, 'method', None): + if method not in SAFE_METHODS and not self.is_exporting(): + return True + else: + # Ignore write_only fields for read requests + if field_kwargs.get('write_only', False): + return False + + # For a top-level serializer, check request query parameters + if self.request and self.filter_on_query and field.filter_by_query: + param_value = self.request.query_params.get(field_ref, None) + + if param_value is not None: + # Convert from list to single value if needed + if type(param_value) == list and len(param_value) == 1: + param_value = param_value[0] + + value = str2bool(param_value) + + # Cache the value for future reference + self.optional_filters[field_ref] = value + + if value is None: + value = field.default_include + + return value + + def find_optional_fields(self): + """Find all optional fields defined on this serializer.""" + optional_fields = {} + + # Walk upwards through the class hierarchy + seen_vars = set() + + for base in self.__class__.__mro__: + for field_name, field in vars(base).items(): + if field_name in seen_vars: + continue + + seen_vars.add(field_name) + + if field and isinstance(field, OptionalField): + optional_fields[field_name] = field + + return optional_fields + + def gather_optional_fields(self, kwargs): + """Determine which optional fields will be included on this serializer. + + Note that there may be instances of OptionalField in the field set, + which need to either be instantiated or removed. + """ + self.optional_filters = {} + self.prefetch_list = set() + self.fields_to_remove = set() + self.optional_fields = set() + + for field_name, field in self.find_optional_fields().items(): + if self.is_field_included(field_name, field, kwargs): + self.optional_fields.add(field_name) + # Add prefetch information + if field.prefetch_fields: + for pf in field.prefetch_fields: + self.prefetch_list.add(pf) + else: + self.fields_to_remove.add(field_name) + + def get_field_names(self, declared_fields, info): + """Remove unused fields before returning field names.""" + field_names = super().get_field_names(declared_fields, info) + + # Add any optional fields which are included + for field_name in self.optional_fields: + if field_name not in field_names: + field_names.append(field_name) + + # Remove any fields which are marked for removal + for field_name in self.fields_to_remove: + if field_name in field_names: + field_names.remove(field_name) + + return field_names + + def build_optional_field(self, field_name: str): + """Build an optional field, based on the provided field name.""" + field = getattr(self, field_name, None) + + if field and isinstance(field, OptionalField): + serializer_kwargs = {**field.serializer_kwargs} or {} + return field.serializer_class, serializer_kwargs + + def build_relational_field(self, field_name, relation_info): + """Handle a special case where an OptionalField shadows a model relation.""" + if field_name in self.optional_fields: + if field := self.build_optional_field(field_name): + return field + + return super().build_relational_field(field_name, relation_info) + + def build_property_field(self, field_name, model_class): + """Handle a special case where an OptionalField shadows a model property.""" + if field_name in self.optional_fields: + if field := self.build_optional_field(field_name): + return field + + return super().build_property_field(field_name, model_class) + + def build_unknown_field(self, field_name, model_class): + """Perform lazy initialization of OptionalFields. + + The DRF framework calls this method when it encounters a field which is not yet initialized. + """ + if field := self.build_optional_field(field_name): + return field + + return super().build_unknown_field(field_name, model_class) def prefetch_queryset(self, queryset: QuerySet) -> QuerySet: """Apply any prefetching to the queryset based on the optionally included fields. @@ -140,157 +286,48 @@ class FilterableSerializerMixin: if getattr(request, '_metadata_requested', False): return queryset - # Gather up the set of simple 'prefetch' fields and functions - prefetch_fields = set() - - filterable_fields = [ - field - for field in self.fields.values() - if getattr(field, 'is_filterable', None) - ] - - for field in filterable_fields: - if prefetch_names := getattr(field, 'prefetch_fields', None): - for pf in prefetch_names: - prefetch_fields.add(pf) - - if prefetch_fields and len(prefetch_fields) > 0: - queryset = queryset.prefetch_related(*list(prefetch_fields)) + if self.prefetch_list and len(self.prefetch_list) > 0: + queryset = queryset.prefetch_related(*list(self.prefetch_list)) return queryset - def gather_filters(self, kwargs) -> None: - """Gather filterable fields through introspection.""" - context = kwargs.get('context', {}) - request = context.get('request', None) or getattr(self, 'request', None) - - # Gather query parameters from the request context - query_params = dict(getattr(request, 'query_params', {})) if request else {} - - # Fast exit if this has already been done or would not have any effect - if getattr(self, '_was_filtered', False) or not hasattr(self, 'fields'): - return - - # Actually gather the filterable fields - # Also see `enable_filter` where` is_filterable and is_filterable_vals are set - self.filter_targets: dict[str, dict] = { - str(k): {'serializer': a, **getattr(a, 'is_filterable_vals', {})} - for k, a in self.fields.items() - if getattr(a, 'is_filterable', None) - } - - # Remove filter args from kwargs to avoid issues with super().__init__ - popped_kwargs = {} # store popped kwargs as a arg might be reused for multiple fields - tgs_vals: dict[str, bool] = {} - for k, v in self.filter_targets.items(): - pop_ref = v['filter_name'] or k - val = kwargs.pop(pop_ref, popped_kwargs.get(pop_ref)) - # Optionally also look in query parameters - # Note that we only do this for a top-level serializer, to avoid issues with nested serializers - if ( - request - and val is None - and self.filter_on_query - and v.get('filter_by_query', True) - ): - val = query_params.pop(pop_ref, None) - - if isinstance(val, list) and len(val) == 1: - val = val[0] - - if val: # Save popped value for reuse - popped_kwargs[pop_ref] = val - tgs_vals[k] = ( - str2bool(val) if isinstance(val, (str, int, float)) else val - ) # Support for various filtering style for backwards compatibility - - self.filter_target_values = tgs_vals - self._was_filtered = True - - # Ensure this mixin is not broadly applied as it is expensive on scale (total CI time increased by 21% when running all coverage tests) - if len(self.filter_targets) == 0 and not self.no_filters: - raise Exception( - 'INVE-I2: No filter targets found in fields, remove `PathScopedMixin`' - ) - - def do_filtering(self) -> None: - """Do the actual filtering.""" - # This serializer might not contain filters or we do not want to pop fields while generating the schema - if ( - not hasattr(self, 'filter_target_values') - or InvenTree.ready.isGeneratingSchema() - ): - return - - is_exporting = getattr(self, '_exporting_data', False) - - # Skip filtering for a write requests - all fields should be present for data creation - if request := self.context.get('request', None): - if method := getattr(request, 'method', None): - if method not in SAFE_METHODS and not is_exporting: - return - - # Throw out fields which are not requested (either by default or explicitly) - for k, v in self.filter_target_values.items(): - # See `enable_filter` where` is_filterable and is_filterable_vals are set - value = v if v is not None else bool(self.filter_targets[k]['default']) - if value is not True: - self.fields.pop(k, None) - - -# special serializers which allow filtering -class FilterableListSerializer( - FilterableSerializerField, FilterableSerializerMixin, serializers.ListSerializer -): - """Custom ListSerializer which allows filtering of fields.""" - - -# special serializer fields which allow filtering -class FilterableListField(FilterableSerializerField, serializers.ListField): - """Custom ListField which allows filtering.""" - - -class FilterableSerializerMethodField( - FilterableSerializerField, serializers.SerializerMethodField -): - """Custom SerializerMethodField which allows filtering.""" - - -class FilterableDateTimeField(FilterableSerializerField, serializers.DateTimeField): - """Custom DateTimeField which allows filtering.""" - - -class FilterableFloatField(FilterableSerializerField, serializers.FloatField): - """Custom FloatField which allows filtering.""" - - -class FilterableCharField(FilterableSerializerField, serializers.CharField): - """Custom CharField which allows filtering.""" - - -class FilterableIntegerField(FilterableSerializerField, serializers.IntegerField): - """Custom IntegerField which allows filtering.""" - - -class FilterableTagListField(FilterableSerializerField, TagListSerializerField): - """Custom TagListSerializerField which allows filtering.""" - - class Meta: - """Empty Meta class.""" - - -# endregion - class EmptySerializer(serializers.Serializer): """Empty serializer for use in testing.""" -class InvenTreeMoneySerializer(FilterableSerializerField, MoneyField): +class TreePathSerializer(serializers.Serializer): + """Serializer field for representing a tree path.""" + + class Meta: + """Metaclass options.""" + + fields = [ + 'pk', + 'name', + # Any fields after this point are optional, and can be included via extra_fields + 'icon', + ] + + def __init__(self, *args, extra_fields: Optional[list[str]] = None, **kwargs): + """Initialize the TreePathSerializer.""" + super().__init__(*args, **kwargs) + + allowed_fields = ['pk', 'name', *(extra_fields or [])] + + for field in list(self.fields.keys()): + if field not in allowed_fields: + self.fields.pop(field, None) + + pk = serializers.IntegerField(read_only=True) + name = serializers.CharField(read_only=True) + icon = serializers.CharField(required=False, read_only=True) + + +class InvenTreeMoneySerializer(MoneyField): """Custom serializer for 'MoneyField', which ensures that passed values are numerically valid. Ref: https://github.com/django-money/django-money/blob/master/djmoney/contrib/django_rest_framework/fields.py - This field allows filtering. """ def __init__(self, *args, **kwargs): @@ -477,7 +514,7 @@ class DependentField(serializers.Field): return None -class InvenTreeModelSerializer(FilterableSerializerField, serializers.ModelSerializer): +class InvenTreeModelSerializer(serializers.ModelSerializer): """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation.""" # Switch out URLField mapping diff --git a/src/backend/InvenTree/InvenTree/test_serializers.py b/src/backend/InvenTree/InvenTree/test_serializers.py index b6c794e33f..aeccf73d4b 100644 --- a/src/backend/InvenTree/InvenTree/test_serializers.py +++ b/src/backend/InvenTree/InvenTree/test_serializers.py @@ -8,6 +8,7 @@ from rest_framework.serializers import SerializerMethodField import InvenTree.serializers from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin +from InvenTree.serializers import OptionalField from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.urls import backendpatterns @@ -25,21 +26,25 @@ class SampleSerializer( fields = ['field_a', 'field_b', 'field_c', 'field_d', 'field_e', 'id'] field_a = SerializerMethodField(method_name='sample') - field_b = InvenTree.serializers.enable_filter( - InvenTree.serializers.FilterableSerializerMethodField(method_name='sample') + field_b = OptionalField( + serializer_class=SerializerMethodField, + serializer_kwargs={'method_name': 'sample'}, ) - field_c = InvenTree.serializers.enable_filter( - InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'), - True, + field_c = OptionalField( + serializer_class=SerializerMethodField, + serializer_kwargs={'method_name': 'sample'}, + default_include=True, filter_name='crazy_name', ) - field_d = InvenTree.serializers.enable_filter( - InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'), - True, + field_d = OptionalField( + serializer_class=SerializerMethodField, + serializer_kwargs={'method_name': 'sample'}, + default_include=True, filter_name='crazy_name', ) - field_e = InvenTree.serializers.enable_filter( - InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'), + field_e = OptionalField( + serializer_class=SerializerMethodField, + serializer_kwargs={'method_name': 'sample'}, filter_name='field_e', filter_by_query=False, ) @@ -106,110 +111,3 @@ class FilteredSerializers(InvenTreeAPITestCase): self.assertContains(response, 'field_c') self.assertContains(response, 'field_d') self.assertNotContains(response, 'field_e') - - def test_failiure_enable_filter(self): - """Test sanity check for enable_filter.""" - # Allowed usage - field_b = InvenTree.serializers.enable_filter( # noqa: F841 - InvenTree.serializers.FilterableSerializerMethodField(method_name='sample') - ) - - # Disallowed usage - with self.assertRaises(Exception) as cm: - field_a = InvenTree.serializers.enable_filter( # noqa: F841 - SerializerMethodField(method_name='sample') - ) - self.assertIn( - 'INVE-I2: `enable_filter` can only be applied to serializer fields', - str(cm.exception), - ) - - def test_failiure_FilterableSerializerMixin(self): - """Test failure case for FilteredSerializerMixin.""" - - class BadSerializer( - InvenTree.serializers.FilterableSerializerMixin, - InvenTree.serializers.InvenTreeModelSerializer, - ): - """Bad serializer for testing FilterableSerializerMixin.""" - - class Meta: - """Meta options.""" - - model = User - fields = ['field_a', 'id'] - - field_a = SerializerMethodField(method_name='sample') - - def sample(self, obj): - """Sample method field.""" - return 'sample' # pragma: no cover - - with self.assertRaises(Exception) as cm: - _ = BadSerializer() - self.assertIn( - 'INVE-I2: No filter targets found in fields, remove `PathScopedMixin`', - str(cm.exception), - ) - - # Test override - BadSerializer.no_filters = True - _ = BadSerializer() - self.assertTrue(True) # Dummy assertion to ensure we reach here - - def test_failure_OutputOptionsMixin(self): - """Test failure case for OutputOptionsMixin.""" - - class BadSerializer(InvenTree.serializers.InvenTreeModelSerializer): - """Sample serializer.""" - - class Meta: - """Meta options.""" - - model = User - fields = ['id'] - - field_a = SerializerMethodField(method_name='sample') - - # Bad implementation of OutputOptionsMixin - with self.assertRaises(Exception) as cm: - - class BadList(OutputOptionsMixin, ListCreateAPI): - """Bad list endpoint for testing OutputOptionsMixin.""" - - serializer_class = BadSerializer - queryset = User.objects.all() - permission_classes = [] - - self.assertTrue(True) - _ = BadList() # this should raise an exception - self.assertEqual( - str(cm.exception), - 'INVE-I2: `OutputOptionsMixin` can only be used with serializers that contain the `FilterableSerializerMixin` mixin', - ) - - # More creative bad implementation - with self.assertRaises(Exception) as cm: - - class BadList(OutputOptionsMixin, ListCreateAPI): - """Bad list endpoint for testing OutputOptionsMixin.""" - - queryset = User.objects.all() - permission_classes = [] - - def get_serializer(self, *args, **kwargs): - """Get serializer override.""" - self.serializer_class = BadSerializer - return super().get_serializer(*args, **kwargs) - - view = BadList() - self.assertTrue(True) - # mock some stuff to allow get_serializer to run - view.request = self.client.request() - view.format_kwarg = {} - view.get_serializer() # this should raise an exception - - self.assertEqual( - str(cm.exception), - 'INVE-I2: `OutputOptionsMixin` can only be used with serializers that contain the `FilterableSerializerMixin` mixin', - ) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 1e1f3c6860..724182c978 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -35,7 +35,7 @@ from InvenTree.serializers import ( InvenTreeDecimalField, InvenTreeModelSerializer, NotesFieldMixin, - enable_filter, + OptionalField, ) from stock.generators import generate_batch_code from stock.models import StockItem, StockLocation @@ -116,9 +116,10 @@ class BuildSerializer( status_text = serializers.CharField(source='get_status_display', read_only=True) - part_detail = enable_filter( - part_serializers.PartBriefSerializer(source='part', many=False, read_only=True), - True, + part_detail = OptionalField( + serializer_class=part_serializers.PartBriefSerializer, + serializer_kwargs={'source': 'part', 'many': False, 'read_only': True}, + default_include=True, prefetch_fields=['part', 'part__category', 'part__pricing_data'], ) @@ -132,16 +133,22 @@ class BuildSerializer( overdue = serializers.BooleanField(read_only=True, default=False) - issued_by_detail = enable_filter( - UserSerializer(source='issued_by', read_only=True), - True, + issued_by_detail = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={'source': 'issued_by', 'read_only': True}, + default_include=True, filter_name='user_detail', prefetch_fields=['issued_by'], ) - responsible_detail = enable_filter( - OwnerSerializer(source='responsible', read_only=True, allow_null=True), - True, + responsible_detail = OptionalField( + serializer_class=OwnerSerializer, + serializer_kwargs={ + 'source': 'responsible', + 'read_only': True, + 'allow_null': True, + }, + default_include=True, filter_name='user_detail', prefetch_fields=['responsible'], ) @@ -1200,31 +1207,33 @@ class BuildItemSerializer( ) # Extra (optional) detail fields - part_detail = enable_filter( - part_serializers.PartBriefSerializer( - label=_('Part'), - source='stock_item.part', - many=False, - read_only=True, - allow_null=True, - pricing=False, - ), - True, + part_detail = OptionalField( + serializer_class=part_serializers.PartBriefSerializer, + serializer_kwargs={ + 'label': _('Part'), + 'source': 'stock_item.part', + 'many': False, + 'read_only': True, + 'allow_null': True, + 'pricing': False, + }, + default_include=True, prefetch_fields=['stock_item__part'], ) - stock_item_detail = enable_filter( - StockItemSerializer( - source='stock_item', - read_only=True, - allow_null=True, - label=_('Stock Item'), - part_detail=False, - location_detail=False, - supplier_part_detail=False, - path_detail=False, - ), - True, + stock_item_detail = OptionalField( + serializer_class=StockItemSerializer, + serializer_kwargs={ + 'source': 'stock_item', + 'read_only': True, + 'allow_null': True, + 'label': _('Stock Item'), + 'part_detail': False, + 'location_detail': False, + 'supplier_part_detail': False, + 'path_detail': False, + }, + default_include=True, filter_name='stock_detail', prefetch_fields=[ 'stock_item', @@ -1234,18 +1243,19 @@ class BuildItemSerializer( ], ) - install_into_detail = enable_filter( - StockItemSerializer( - source='install_into', - read_only=True, - allow_null=True, - label=_('Install Into'), - part_detail=False, - location_detail=False, - supplier_part_detail=False, - path_detail=False, - ), - False, + install_into_detail = OptionalField( + serializer_class=StockItemSerializer, + serializer_kwargs={ + 'source': 'install_into', + 'read_only': True, + 'allow_null': True, + 'label': _('Install Into'), + 'part_detail': False, + 'location_detail': False, + 'supplier_part_detail': False, + 'path_detail': False, + }, + default_include=False, prefetch_fields=['install_into', 'install_into__part'], ) @@ -1253,26 +1263,28 @@ class BuildItemSerializer( label=_('Location'), source='stock_item.location', many=False, read_only=True ) - location_detail = enable_filter( - LocationBriefSerializer( - label=_('Location'), - source='stock_item.location', - read_only=True, - allow_null=True, - ), - True, + location_detail = OptionalField( + serializer_class=LocationBriefSerializer, + serializer_kwargs={ + 'label': _('Location'), + 'source': 'stock_item.location', + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=['stock_item__location'], ) - build_detail = enable_filter( - BuildSerializer( - label=_('Build'), - source='build_line.build', - many=False, - read_only=True, - allow_null=True, - ), - True, + build_detail = OptionalField( + serializer_class=BuildSerializer, + serializer_kwargs={ + 'label': _('Build'), + 'source': 'build_line.build', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=[ 'build_line__build', 'build_line__build__part', @@ -1283,16 +1295,17 @@ class BuildItemSerializer( ], ) - supplier_part_detail = enable_filter( - company.serializers.SupplierPartSerializer( - label=_('Supplier Part'), - source='stock_item.supplier_part', - many=False, - read_only=True, - allow_null=True, - brief=True, - ), - False, + supplier_part_detail = OptionalField( + serializer_class=company.serializers.SupplierPartSerializer, + serializer_kwargs={ + 'label': _('Supplier Part'), + 'source': 'stock_item.supplier_part', + 'many': False, + 'read_only': True, + 'allow_null': True, + 'brief': True, + }, + default_include=False, prefetch_fields=[ 'stock_item__supplier_part', 'stock_item__supplier_part__supplier', @@ -1382,11 +1395,15 @@ class BuildLineSerializer( read_only=True, ) - allocations = enable_filter( - BuildItemSerializer( - many=True, read_only=True, allow_null=True, build_detail=False - ), - True, + allocations = OptionalField( + serializer_class=BuildItemSerializer, + serializer_kwargs={ + 'many': True, + 'read_only': True, + 'allow_null': True, + 'build_detail': False, + }, + default_include=True, prefetch_fields=[ 'allocations', 'allocations__stock_item', @@ -1427,73 +1444,79 @@ class BuildLineSerializer( bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True) # Foreign key fields - bom_item_detail = enable_filter( - part_serializers.BomItemSerializer( - label=_('BOM Item'), - source='bom_item', - many=False, - read_only=True, - allow_null=True, - pricing=False, - substitutes=False, - sub_part_detail=False, - part_detail=False, - can_build=False, - ), - False, + bom_item_detail = OptionalField( + serializer_class=part_serializers.BomItemSerializer, + serializer_kwargs={ + 'label': _('BOM Item'), + 'source': 'bom_item', + 'many': False, + 'read_only': True, + 'allow_null': True, + 'pricing': False, + 'substitutes': False, + 'sub_part_detail': False, + 'part_detail': False, + 'can_build': False, + }, + default_include=False, prefetch_fields=['bom_item'], ) - assembly_detail = enable_filter( - part_serializers.PartBriefSerializer( - label=_('Assembly'), - source='bom_item.part', - many=False, - read_only=True, - allow_null=True, - pricing=False, - ), - False, + assembly_detail = OptionalField( + serializer_class=part_serializers.PartBriefSerializer, + serializer_kwargs={ + 'label': _('Assembly'), + 'source': 'bom_item.part', + 'many': False, + 'read_only': True, + 'allow_null': True, + 'pricing': False, + }, + default_include=False, prefetch_fields=['bom_item__part', 'bom_item__part__pricing_data'], ) - part_detail = enable_filter( - part_serializers.PartBriefSerializer( - label=_('Part'), - source='bom_item.sub_part', - many=False, - read_only=True, - allow_null=True, - pricing=False, - ), - False, + part_detail = OptionalField( + serializer_class=part_serializers.PartBriefSerializer, + serializer_kwargs={ + 'label': _('Part'), + 'source': 'bom_item.sub_part', + 'many': False, + 'read_only': True, + 'allow_null': True, + 'pricing': False, + }, + default_include=False, prefetch_fields=['bom_item__sub_part', 'bom_item__sub_part__pricing_data'], ) - category_detail = enable_filter( - part_serializers.CategorySerializer( - label=_('Category'), - source='bom_item.sub_part.category', - many=False, - read_only=True, - allow_null=True, - ), - False, + category_detail = OptionalField( + serializer_class=part_serializers.CategorySerializer, + serializer_kwargs={ + 'label': _('Category'), + 'source': 'bom_item.sub_part.category', + 'many': False, + 'read_only': True, + 'allow_null': True, + 'path_detail': False, + }, + default_include=False, prefetch_fields=['bom_item__sub_part__category'], ) - build_detail = enable_filter( - BuildSerializer( - label=_('Build'), - source='build', - many=False, - read_only=True, - allow_null=True, - part_detail=False, - user_detail=False, - project_code_detail=False, - ), - True, + build_detail = OptionalField( + serializer_class=BuildSerializer, + serializer_kwargs={ + 'label': _('Build'), + 'source': 'build', + 'many': False, + 'read_only': True, + 'allow_null': True, + 'part_detail': False, + 'user_detail': False, + 'project_code_detail': False, + }, + default_include=True, ) # Annotated (calculated) fields diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 26fdaf201e..e63a313238 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -1162,18 +1162,12 @@ class BuildListTest(BuildAPITest): data = self.options(self.url, expected_code=200).data self.assertEqual(data['name'], 'Build List') - actions = data['actions']['POST'] + actions = data['actions']['GET'] - for field_name in [ - 'pk', - 'title', - 'part', - 'part_detail', - 'project_code', - 'project_code_detail', - 'quantity', - ]: + for field_name in ['pk', 'title', 'part', 'project_code', 'quantity']: + # Fields should exist in both GET and POST actions self.assertIn(field_name, actions) + self.assertIn(field_name, data['actions']['POST']) # Specific checks for certain fields for field_name in ['part', 'project_code', 'take_from']: diff --git a/src/backend/InvenTree/common/filters.py b/src/backend/InvenTree/common/filters.py index 35b59908ed..c3cff3677d 100644 --- a/src/backend/InvenTree/common/filters.py +++ b/src/backend/InvenTree/common/filters.py @@ -19,6 +19,9 @@ from django.db.models import ( from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from taggit.serializers import TagListSerializerField + import InvenTree.conversion import InvenTree.helpers import InvenTree.serializers @@ -325,11 +328,15 @@ def enable_project_code_filter(default: bool = True): """ from common.serializers import ProjectCodeSerializer - return InvenTree.serializers.enable_filter( - ProjectCodeSerializer( - source='project_code', many=False, read_only=True, allow_null=True - ), - default, + return InvenTree.serializers.OptionalField( + serializer_class=ProjectCodeSerializer, + serializer_kwargs={ + 'source': 'project_code', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=default, filter_name='project_code_detail', prefetch_fields=['project_code'], ) @@ -344,14 +351,15 @@ def enable_project_label_filter(default: bool = True): If applied, this field will automatically prefetch the 'project_code' relationship. """ - return InvenTree.serializers.enable_filter( - InvenTree.serializers.FilterableCharField( - source='project_code.code', - read_only=True, - label=_('Project Code Label'), - allow_null=True, - ), - default, + return InvenTree.serializers.OptionalField( + serializer_class=serializers.CharField, + serializer_kwargs={ + 'source': 'project_code.code', + 'read_only': True, + 'label': _('Project Code Label'), + 'allow_null': True, + }, + default_include=default, filter_name='project_code_detail', prefetch_fields=['project_code'], ) @@ -369,9 +377,15 @@ def enable_parameters_filter(): """ from common.serializers import ParameterSerializer - return InvenTree.serializers.enable_filter( - ParameterSerializer(many=True, read_only=True, allow_null=True), - False, + return InvenTree.serializers.OptionalField( + serializer_class=ParameterSerializer, + serializer_kwargs={ + 'many': True, + 'read_only': True, + 'allow_null': True, + 'required': False, + }, + default_include=False, filter_name='parameters', prefetch_fields=[ 'parameters_list', @@ -390,11 +404,10 @@ def enable_tags_filter(default: bool = False): If applied, this field will automatically prefetch the 'tags' relationship. """ - from InvenTree.serializers import FilterableTagListField - - return InvenTree.serializers.enable_filter( - FilterableTagListField(required=False), - default, + return InvenTree.serializers.OptionalField( + serializer_class=TagListSerializerField, + serializer_kwargs={'required': False}, + default_include=default, filter_name='tags', prefetch_fields=['tags', 'tagged_items', 'tagged_items__tag'], ) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 574c45f265..35f7808750 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -28,7 +28,7 @@ from InvenTree.serializers import ( InvenTreeAttachmentSerializerField, InvenTreeImageSerializerField, InvenTreeModelSerializer, - enable_filter, + OptionalField, ) from plugin import registry as plugin_registry from users.serializers import OwnerSerializer, UserSerializer @@ -857,6 +857,7 @@ class ParameterSerializer( 'note', 'updated', 'updated_by', + # Optional fields 'template_detail', 'updated_by_detail', ] @@ -906,17 +907,22 @@ class ParameterSerializer( allow_null=False, ) - updated_by_detail = enable_filter( - UserSerializer( - source='updated_by', read_only=True, allow_null=True, many=False - ), - True, + updated_by_detail = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={ + 'source': 'updated_by', + 'read_only': True, + 'allow_null': True, + 'many': False, + }, + default_include=True, prefetch_fields=['updated_by'], ) - template_detail = enable_filter( - ParameterTemplateSerializer(source='template', read_only=True, many=False), - True, + template_detail = OptionalField( + serializer_class=ParameterTemplateSerializer, + serializer_kwargs={'source': 'template', 'read_only': True, 'many': False}, + default_include=True, prefetch_fields=['template', 'template__model_type'], ) diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index feaca0a354..3702c0c71e 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -179,6 +179,7 @@ class ManufacturerPartMixin(SerializerContextMixin): queryset = super().get_queryset(*args, **kwargs) queryset = queryset.prefetch_related('supplier_parts') + queryset = queryset.prefetch_related('part', 'part__pricing_data') return queryset diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index eae59f1980..616941eda5 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -17,7 +17,6 @@ from importer.registry import register_importer from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import ( - FilterableCharField, FilterableSerializerMixin, InvenTreeCurrencySerializer, InvenTreeDecimalField, @@ -26,8 +25,8 @@ from InvenTree.serializers import ( InvenTreeMoneySerializer, InvenTreeTagModelSerializer, NotesFieldMixin, + OptionalField, RemoteImageMixin, - enable_filter, ) from .models import ( @@ -164,9 +163,10 @@ class CompanySerializer( return queryset - primary_address = enable_filter( - AddressBriefSerializer(read_only=True, allow_null=True), - False, + primary_address = OptionalField( + serializer_class=AddressBriefSerializer, + serializer_kwargs={'read_only': True, 'many': False, 'allow_null': True}, + default_include=False, filter_name='address_detail', prefetch_fields=[ Prefetch( @@ -263,27 +263,37 @@ class ManufacturerPartSerializer( parameters = common.filters.enable_parameters_filter() - part_detail = enable_filter( - part_serializers.PartBriefSerializer( - source='part', many=False, read_only=True, allow_null=True - ), - True, - prefetch_fields=['part', 'part__pricing_data', 'part__category'], + part_detail = OptionalField( + serializer_class=part_serializers.PartBriefSerializer, + serializer_kwargs={ + 'source': 'part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, + prefetch_fields=['part__category'], ) - pretty_name = enable_filter( - FilterableCharField(read_only=True, allow_null=True), filter_name='pretty' + pretty_name = OptionalField( + serializer_class=serializers.CharField, + serializer_kwargs={'read_only': True, 'allow_null': True}, + filter_name='pretty', ) manufacturer = serializers.PrimaryKeyRelatedField( queryset=Company.objects.filter(is_manufacturer=True) ) - manufacturer_detail = enable_filter( - CompanyBriefSerializer( - source='manufacturer', many=False, read_only=True, allow_null=True - ), - True, + manufacturer_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'manufacturer', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=['manufacturer'], ) @@ -415,70 +425,86 @@ class SupplierPartSerializer( pack_quantity_native = serializers.FloatField(read_only=True) - price_breaks = enable_filter( - SupplierPriceBreakBriefSerializer( - source='pricebreaks', - many=True, - read_only=True, - allow_null=True, - label=_('Price Breaks'), - ), - False, + price_breaks = OptionalField( + serializer_class=SupplierPriceBreakBriefSerializer, + serializer_kwargs={ + 'source': 'pricebreaks', + 'many': True, + 'read_only': True, + 'allow_null': True, + 'label': _('Price Breaks'), + }, + default_include=False, filter_name='price_breaks', prefetch_fields=['pricebreaks'], ) parameters = common.filters.enable_parameters_filter() - part_detail = enable_filter( - part_serializers.PartBriefSerializer( - label=_('Part'), source='part', many=False, read_only=True, allow_null=True - ), - False, + part_detail = OptionalField( + serializer_class=part_serializers.PartBriefSerializer, + serializer_kwargs={ + 'label': _('Part'), + 'source': 'part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=['part', 'part__pricing_data'], ) - supplier_detail = enable_filter( - CompanyBriefSerializer( - label=_('Supplier'), - source='supplier', - many=False, - read_only=True, - allow_null=True, - ), - False, + supplier_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'label': _('Supplier'), + 'source': 'supplier', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=['supplier'], ) - manufacturer_detail = enable_filter( - CompanyBriefSerializer( - label=_('Manufacturer'), - source='manufacturer_part.manufacturer', - many=False, - read_only=True, - allow_null=True, - ), - False, + manufacturer_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'label': _('Manufacturer'), + 'source': 'manufacturer_part.manufacturer', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=['manufacturer_part__manufacturer'], ) - pretty_name = enable_filter( - FilterableCharField(read_only=True, allow_null=True), filter_name='pretty' + pretty_name = OptionalField( + serializer_class=serializers.CharField, + serializer_kwargs={ + 'read_only': True, + 'allow_null': True, + 'label': _('Pretty Name'), + }, + default_include=False, + filter_name='pretty', ) supplier = serializers.PrimaryKeyRelatedField( label=_('Supplier'), queryset=Company.objects.filter(is_supplier=True) ) - manufacturer_part_detail = enable_filter( - ManufacturerPartSerializer( - label=_('Manufacturer Part'), - source='manufacturer_part', - part_detail=False, - read_only=True, - allow_null=True, - ), - False, + manufacturer_part_detail = OptionalField( + serializer_class=ManufacturerPartSerializer, + serializer_kwargs={ + 'label': _('Manufacturer Part'), + 'source': 'manufacturer_part', + 'part_detail': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=['manufacturer_part'], ) @@ -568,18 +594,27 @@ class SupplierPriceBreakSerializer( return queryset - supplier_detail = enable_filter( - CompanyBriefSerializer( - source='part.supplier', many=False, read_only=True, allow_null=True - ), - False, + supplier_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'part.supplier', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=['part__supplier'], ) - part_detail = enable_filter( - SupplierPartSerializer( - source='part', brief=True, many=False, read_only=True, allow_null=True - ), - False, + part_detail = OptionalField( + serializer_class=SupplierPartSerializer, + serializer_kwargs={ + 'source': 'part', + 'brief': True, + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=['part', 'part__part', 'part__part__pricing_data'], ) diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 6df854edd6..8570991643 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -507,6 +507,8 @@ class ManufacturerTest(InvenTreeAPITestCase): """Tests for the ManufacturerPart detail endpoint.""" mp = ManufacturerPart.objects.first() + self.assertIsNotNone(mp) + url = reverse('api-manufacturer-part-detail', kwargs={'pk': mp.pk}) response = self.get(url) diff --git a/src/backend/InvenTree/importer/models.py b/src/backend/InvenTree/importer/models.py index 76f5d8dcc9..d1c714bffa 100644 --- a/src/backend/InvenTree/importer/models.py +++ b/src/backend/InvenTree/importer/models.py @@ -394,7 +394,14 @@ class DataImportSession(models.Model): if serializer_class := self.serializer_class: serializer = serializer_class(data={}, importing=True) - fields.update(metadata.get_serializer_info(serializer)) + serializer_fields = metadata.get_serializer_info(serializer) + + for field_name, field in serializer_fields.items(): + # Skip read-only fields + if field.get('read_only', False): + continue + + fields[field_name] = field # Cache the available fields against this instance self._available_fields = fields diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index f79e6d076b..e1a8de16f6 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -36,7 +36,7 @@ from InvenTree.serializers import ( InvenTreeModelSerializer, InvenTreeMoneySerializer, NotesFieldMixin, - enable_filter, + OptionalField, ) from order.status_codes import ( PurchaseOrderStatusGroups, @@ -126,20 +126,28 @@ class AbstractOrderSerializer( reference = serializers.CharField(required=True) # Detail for point-of-contact field - contact_detail = enable_filter( - ContactSerializer( - source='contact', many=False, read_only=True, allow_null=True - ), - True, + contact_detail = OptionalField( + serializer_class=ContactSerializer, + serializer_kwargs={ + 'source': 'contact', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=['contact'], ) # Detail for responsible field - responsible_detail = enable_filter( - OwnerSerializer( - source='responsible', read_only=True, allow_null=True, many=False - ), - True, + responsible_detail = OptionalField( + serializer_class=OwnerSerializer, + serializer_kwargs={ + 'source': 'responsible', + 'read_only': True, + 'allow_null': True, + 'many': False, + }, + default_include=True, prefetch_fields=['responsible'], ) @@ -147,11 +155,15 @@ class AbstractOrderSerializer( project_code_detail = common.filters.enable_project_code_filter() # Detail for address field - address_detail = enable_filter( - AddressBriefSerializer( - source='address', many=False, read_only=True, allow_null=True - ), - True, + address_detail = OptionalField( + serializer_class=AddressBriefSerializer, + serializer_kwargs={ + 'source': 'address', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=['address'], ) @@ -432,10 +444,14 @@ class PurchaseOrderSerializer( source='supplier.name', read_only=True, label=_('Supplier Name') ) - supplier_detail = enable_filter( - CompanyBriefSerializer( - source='supplier', many=False, read_only=True, allow_null=True - ), + supplier_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'supplier', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['supplier'], ) @@ -629,19 +645,28 @@ class PurchaseOrderLineItemSerializer( total_price = serializers.FloatField(read_only=True) - part_detail = enable_filter( - PartBriefSerializer( - source='get_base_part', many=False, read_only=True, allow_null=True - ), - False, + part_detail = OptionalField( + serializer_class=PartBriefSerializer, + serializer_kwargs={ + 'source': 'get_base_part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, filter_name='part_detail', ) - supplier_part_detail = enable_filter( - SupplierPartSerializer( - source='part', brief=True, many=False, read_only=True, allow_null=True - ), - False, + supplier_part_detail = OptionalField( + serializer_class=SupplierPartSerializer, + serializer_kwargs={ + 'source': 'part', + 'brief': True, + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, filter_name='part_detail', ) @@ -655,11 +680,14 @@ class PurchaseOrderLineItemSerializer( default=False, ) - destination_detail = enable_filter( - stock.serializers.LocationBriefSerializer( - source='get_destination', read_only=True, allow_null=True - ), - True, + destination_detail = OptionalField( + serializer_class=stock.serializers.LocationBriefSerializer, + serializer_kwargs={ + 'source': 'get_destination', + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=['destination', 'order__destination'], ) @@ -667,17 +695,26 @@ class PurchaseOrderLineItemSerializer( help_text=_('Purchase price currency') ) - order_detail = enable_filter( - PurchaseOrderSerializer( - source='order', read_only=True, allow_null=True, many=False - ) + order_detail = OptionalField( + serializer_class=PurchaseOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'read_only': True, + 'allow_null': True, + 'many': False, + }, + default_include=True, ) - build_order_detail = enable_filter( - build.serializers.BuildSerializer( - source='build_order', read_only=True, allow_null=True, many=False - ), - True, + build_order_detail = OptionalField( + serializer_class=build.serializers.BuildSerializer, + serializer_kwargs={ + 'source': 'build_order', + 'read_only': True, + 'allow_null': True, + 'many': False, + }, + default_include=True, prefetch_fields=[ 'build_order__responsible', 'build_order__issued_by', @@ -763,10 +800,14 @@ class PurchaseOrderExtraLineSerializer( model = order.models.PurchaseOrderExtraLine fields = AbstractExtraLineSerializer.extra_line_fields([]) - order_detail = enable_filter( - PurchaseOrderSerializer( - source='order', many=False, read_only=True, allow_null=True - ) + order_detail = OptionalField( + serializer_class=PurchaseOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, ) @@ -1098,10 +1139,14 @@ class SalesOrderSerializer( return queryset - customer_detail = enable_filter( - CompanyBriefSerializer( - source='customer', many=False, read_only=True, allow_null=True - ), + customer_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'customer', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['customer'], ) @@ -1252,10 +1297,14 @@ class SalesOrderLineItemSerializer( return queryset - order_detail = enable_filter( - SalesOrderSerializer( - source='order', many=False, read_only=True, allow_null=True - ), + order_detail = OptionalField( + serializer_class=SalesOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=[ 'order__created_by', 'order__responsible', @@ -1265,15 +1314,25 @@ class SalesOrderLineItemSerializer( ], ) - part_detail = enable_filter( - PartBriefSerializer(source='part', many=False, read_only=True, allow_null=True), + part_detail = OptionalField( + serializer_class=PartBriefSerializer, + serializer_kwargs={ + 'source': 'part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['part__pricing_data'], ) - customer_detail = enable_filter( - CompanyBriefSerializer( - source='order.customer', many=False, read_only=True, allow_null=True - ), + customer_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'order.customer', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['order__customer'], ) @@ -1343,19 +1402,27 @@ class SalesOrderShipmentSerializer( read_only=True, allow_null=True, label=_('Allocated Items') ) - checked_by_detail = enable_filter( - UserSerializer( - source='checked_by', many=False, read_only=True, allow_null=True - ), - True, + checked_by_detail = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={ + 'source': 'checked_by', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=['checked_by'], ) - order_detail = enable_filter( - SalesOrderSerializer( - source='order', read_only=True, allow_null=True, many=False - ), - True, + order_detail = OptionalField( + serializer_class=SalesOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=[ 'order', 'order__customer', @@ -1365,19 +1432,27 @@ class SalesOrderShipmentSerializer( ], ) - customer_detail = enable_filter( - CompanyBriefSerializer( - source='order.customer', many=False, read_only=True, allow_null=True - ), - False, + customer_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'order.customer', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=['order__customer'], ) - shipment_address_detail = enable_filter( - AddressBriefSerializer( - source='shipment_address', many=False, read_only=True, allow_null=True - ), - True, + shipment_address_detail = OptionalField( + serializer_class=AddressBriefSerializer, + serializer_kwargs={ + 'source': 'shipment_address', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=['shipment_address'], ) @@ -1428,46 +1503,71 @@ class SalesOrderAllocationSerializer( ) # Extra detail fields - order_detail = enable_filter( - SalesOrderSerializer( - source='line.order', many=False, read_only=True, allow_null=True - ) - ) - part_detail = enable_filter( - PartBriefSerializer( - source='item.part', many=False, read_only=True, allow_null=True - ), - True, - ) - item_detail = enable_filter( - stock.serializers.StockItemSerializer( - source='item', - many=False, - read_only=True, - allow_null=True, - part_detail=False, - location_detail=False, - supplier_part_detail=False, - ), - True, - ) - location_detail = enable_filter( - stock.serializers.LocationBriefSerializer( - source='item.location', many=False, read_only=True, allow_null=True - ) - ) - customer_detail = enable_filter( - CompanyBriefSerializer( - source='line.order.customer', many=False, read_only=True, allow_null=True - ) + order_detail = OptionalField( + serializer_class=SalesOrderSerializer, + serializer_kwargs={ + 'source': 'line.order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, ) - shipment_detail = SalesOrderShipmentSerializer( - source='shipment', - order_detail=False, - many=False, - read_only=True, - allow_null=True, + part_detail = OptionalField( + serializer_class=PartBriefSerializer, + serializer_kwargs={ + 'source': 'item.part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, + ) + + item_detail = OptionalField( + serializer_class=stock.serializers.StockItemSerializer, + serializer_kwargs={ + 'source': 'item', + 'many': False, + 'read_only': True, + 'allow_null': True, + 'part_detail': False, + 'location_detail': False, + 'supplier_part_detail': False, + }, + default_include=True, + ) + location_detail = OptionalField( + serializer_class=stock.serializers.LocationBriefSerializer, + serializer_kwargs={ + 'source': 'item.location', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + ) + + customer_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'line.order.customer', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + ) + + shipment_detail = OptionalField( + serializer_class=SalesOrderShipmentSerializer, + serializer_kwargs={ + 'source': 'shipment', + 'order_detail': False, + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, + prefetch_fields=['shipment'], ) @@ -1881,10 +1981,14 @@ class SalesOrderExtraLineSerializer( model = order.models.SalesOrderExtraLine fields = AbstractExtraLineSerializer.extra_line_fields([]) - order_detail = enable_filter( - SalesOrderSerializer( - source='order', many=False, read_only=True, allow_null=True - ) + order_detail = OptionalField( + serializer_class=SalesOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, ) @@ -1942,10 +2046,14 @@ class ReturnOrderSerializer( return queryset - customer_detail = enable_filter( - CompanyBriefSerializer( - source='customer', many=False, read_only=True, allow_null=True - ), + customer_detail = OptionalField( + serializer_class=CompanyBriefSerializer, + serializer_kwargs={ + 'source': 'customer', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['customer'], ) @@ -2103,10 +2211,14 @@ class ReturnOrderLineItemSerializer( 'part_detail', ]) - order_detail = enable_filter( - ReturnOrderSerializer( - source='order', many=False, read_only=True, allow_null=True - ), + order_detail = OptionalField( + serializer_class=ReturnOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=[ 'order__created_by', 'order__responsible', @@ -2120,17 +2232,25 @@ class ReturnOrderLineItemSerializer( label=_('Quantity'), help_text=_('Quantity to return') ) - item_detail = enable_filter( - stock.serializers.StockItemSerializer( - source='item', many=False, read_only=True, allow_null=True - ), + item_detail = OptionalField( + serializer_class=stock.serializers.StockItemSerializer, + serializer_kwargs={ + 'source': 'item', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['item__supplier_part'], ) - part_detail = enable_filter( - PartBriefSerializer( - source='item.part', many=False, read_only=True, allow_null=True - ) + part_detail = OptionalField( + serializer_class=PartBriefSerializer, + serializer_kwargs={ + 'source': 'item.part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, ) price = InvenTreeMoneySerializer(allow_null=True) @@ -2149,8 +2269,12 @@ class ReturnOrderExtraLineSerializer( model = order.models.ReturnOrderExtraLine fields = AbstractExtraLineSerializer.extra_line_fields([]) - order_detail = enable_filter( - ReturnOrderSerializer( - source='order', many=False, read_only=True, allow_null=True - ) + order_detail = OptionalField( + serializer_class=ReturnOrderSerializer, + serializer_kwargs={ + 'source': 'order', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, ) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 8788ca8624..eb8746be18 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -35,13 +35,7 @@ from data_exporter.mixins import DataExportSerializerMixin from importer.registry import register_importer from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.ready import isGeneratingSchema -from InvenTree.serializers import ( - FilterableDateTimeField, - FilterableFloatField, - FilterableListField, - FilterableListSerializer, - enable_filter, -) +from InvenTree.serializers import OptionalField, TreePathSerializer from users.serializers import UserSerializer from .models import ( @@ -141,13 +135,15 @@ class CategorySerializer( return category.pk in self.starred_categories - path = enable_filter( - FilterableListField( - child=serializers.DictField(), - source='get_path', - read_only=True, - allow_null=True, - ), + path = OptionalField( + serializer_class=TreePathSerializer, + serializer_kwargs={ + 'source': 'get_path', + 'many': True, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, filter_name='path_detail', ) @@ -354,18 +350,25 @@ class PartBriefSerializer( ) # Pricing fields - pricing_min = enable_filter( - InvenTree.serializers.InvenTreeMoneySerializer( - source='pricing_data.overall_min', allow_null=True, read_only=True - ), - True, + pricing_min = OptionalField( + serializer_class=InvenTree.serializers.InvenTreeMoneySerializer, + serializer_kwargs={ + 'source': 'pricing_data.overall_min', + 'allow_null': True, + 'read_only': True, + }, + default_include=True, filter_name='pricing', ) - pricing_max = enable_filter( - InvenTree.serializers.InvenTreeMoneySerializer( - source='pricing_data.overall_max', allow_null=True, read_only=True - ), - True, + + pricing_max = OptionalField( + serializer_class=InvenTree.serializers.InvenTreeMoneySerializer, + serializer_kwargs={ + 'source': 'pricing_data.overall_max', + 'allow_null': True, + 'read_only': True, + }, + default_include=True, filter_name='pricing', ) @@ -774,28 +777,37 @@ class PartSerializer( return part.pk in self.starred_parts # Extra detail for the category - category_detail = enable_filter( - CategorySerializer( - source='category', many=False, read_only=True, allow_null=True - ), + category_detail = OptionalField( + serializer_class=CategorySerializer, + serializer_kwargs={ + 'source': 'category', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['category'], ) - category_path = enable_filter( - FilterableListField( - child=serializers.DictField(), - source='category.get_path', - read_only=True, - allow_null=True, - ), + category_path = OptionalField( + serializer_class=TreePathSerializer, + serializer_kwargs={ + 'source': 'category.get_path', + 'many': True, + 'read_only': True, + 'allow_null': True, + }, filter_name='path_detail', prefetch_fields=['category'], ) - default_location_detail = enable_filter( - DefaultLocationSerializer( - source='default_location', many=False, read_only=True, allow_null=True - ), + default_location_detail = OptionalField( + serializer_class=DefaultLocationSerializer, + serializer_kwargs={ + 'source': 'default_location', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, filter_name='location_detail', prefetch_fields=['default_location'], ) @@ -901,25 +913,36 @@ class PartSerializer( ) # Pricing fields - pricing_min = enable_filter( - InvenTree.serializers.InvenTreeMoneySerializer( - source='pricing_data.overall_min', allow_null=True, read_only=True - ), - True, + pricing_min = OptionalField( + serializer_class=InvenTree.serializers.InvenTreeMoneySerializer, + serializer_kwargs={ + 'source': 'pricing_data.overall_min', + 'allow_null': True, + 'read_only': True, + }, + default_include=True, filter_name='pricing', ) - pricing_max = enable_filter( - InvenTree.serializers.InvenTreeMoneySerializer( - source='pricing_data.overall_max', allow_null=True, read_only=True - ), - True, + + pricing_max = OptionalField( + serializer_class=InvenTree.serializers.InvenTreeMoneySerializer, + serializer_kwargs={ + 'source': 'pricing_data.overall_max', + 'allow_null': True, + 'read_only': True, + }, + default_include=True, filter_name='pricing', ) - pricing_updated = enable_filter( - FilterableDateTimeField( - source='pricing_data.updated', allow_null=True, read_only=True - ), - True, + + pricing_updated = OptionalField( + serializer_class=serializers.DateTimeField, + serializer_kwargs={ + 'source': 'pricing_data.updated', + 'allow_null': True, + 'read_only': True, + }, + default_include=True, filter_name='pricing', ) @@ -927,11 +950,15 @@ class PartSerializer( tags = common.filters.enable_tags_filter() - price_breaks = enable_filter( - PartSalePriceSerializer( - source='salepricebreaks', many=True, read_only=True, allow_null=True - ), - False, + price_breaks = OptionalField( + serializer_class=PartSalePriceSerializer, + serializer_kwargs={ + 'source': 'salepricebreaks', + 'many': True, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, filter_name='price_breaks', prefetch_fields=['salepricebreaks'], ) @@ -1267,10 +1294,15 @@ class PartStocktakeSerializer( label=_('Part Description'), ) - part_detail = enable_filter( - PartBriefSerializer( - source='part', read_only=True, allow_null=True, many=False, pricing=False - ), + part_detail = OptionalField( + serializer_class=PartBriefSerializer, + serializer_kwargs={ + 'source': 'part', + 'read_only': True, + 'allow_null': True, + 'many': False, + 'pricing': False, + }, default_include=False, ) @@ -1595,7 +1627,7 @@ class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer model = BomItemSubstitute fields = ['pk', 'bom_item', 'part', 'part_detail'] - list_serializer_class = FilterableListSerializer + # list_serializer_class = FilterableListSerializer part_detail = PartBriefSerializer( source='part', read_only=True, many=False, pricing=False @@ -1697,9 +1729,15 @@ class BomItemSerializer( help_text=_('Select the parent assembly'), ) - substitutes = enable_filter( - BomItemSubstituteSerializer(many=True, read_only=True, allow_null=True), - False, + substitutes = OptionalField( + serializer_class=BomItemSubstituteSerializer, + serializer_kwargs={ + 'many': True, + 'read_only': True, + 'allow_null': True, + 'required': False, + }, + default_include=False, filter_name='substitutes', prefetch_fields=[ 'substitutes', @@ -1709,14 +1747,15 @@ class BomItemSerializer( ], ) - part_detail = enable_filter( - PartBriefSerializer( - source='part', - label=_('Assembly'), - many=False, - read_only=True, - allow_null=True, - ) + part_detail = OptionalField( + serializer_class=PartBriefSerializer, + serializer_kwargs={ + 'source': 'part', + 'label': _('Assembly'), + 'many': False, + 'read_only': True, + 'allow_null': True, + }, ) sub_part = serializers.PrimaryKeyRelatedField( @@ -1725,26 +1764,28 @@ class BomItemSerializer( help_text=_('Select the component part'), ) - sub_part_detail = enable_filter( - PartBriefSerializer( - source='sub_part', - label=_('Component'), - many=False, - read_only=True, - allow_null=True, - ), - True, + sub_part_detail = OptionalField( + serializer_class=PartBriefSerializer, + serializer_kwargs={ + 'source': 'sub_part', + 'label': _('Component'), + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, ) - category_detail = enable_filter( - CategorySerializer( - source='sub_part.category', - label=_('Category'), - many=False, - read_only=True, - allow_null=True, - ), - False, + category_detail = OptionalField( + serializer_class=CategorySerializer, + serializer_kwargs={ + 'source': 'sub_part.category', + 'label': _('Category'), + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, ) on_order = serializers.FloatField( @@ -1755,41 +1796,61 @@ class BomItemSerializer( label=_('In Production'), read_only=True, allow_null=True ) - can_build = enable_filter( - FilterableFloatField(label=_('Can Build'), read_only=True, allow_null=True), - True, + can_build = OptionalField( + serializer_class=serializers.FloatField, + serializer_kwargs={ + 'label': _('Can Build'), + 'read_only': True, + 'allow_null': True, + }, + default_include=True, ) # Cached pricing fields - pricing_min = enable_filter( - InvenTree.serializers.InvenTreeMoneySerializer( - source='sub_part.pricing_data.overall_min', allow_null=True, read_only=True - ), - True, + pricing_min = OptionalField( + serializer_class=InvenTree.serializers.InvenTreeMoneySerializer, + serializer_kwargs={ + 'source': 'sub_part.pricing_data.overall_min', + 'allow_null': True, + 'read_only': True, + }, + default_include=True, filter_name='pricing', ) - pricing_max = enable_filter( - InvenTree.serializers.InvenTreeMoneySerializer( - source='sub_part.pricing_data.overall_max', allow_null=True, read_only=True - ), - True, + + pricing_max = OptionalField( + serializer_class=InvenTree.serializers.InvenTreeMoneySerializer, + serializer_kwargs={ + 'source': 'sub_part.pricing_data.overall_max', + 'allow_null': True, + 'read_only': True, + }, + default_include=True, filter_name='pricing', ) - pricing_min_total = enable_filter( - InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True), - True, + + pricing_min_total = OptionalField( + serializer_class=InvenTree.serializers.InvenTreeMoneySerializer, + serializer_kwargs={'allow_null': True, 'read_only': True}, + default_include=True, filter_name='pricing', ) - pricing_max_total = enable_filter( - InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True), - True, + + pricing_max_total = OptionalField( + serializer_class=InvenTree.serializers.InvenTreeMoneySerializer, + serializer_kwargs={'allow_null': True, 'read_only': True}, + default_include=True, filter_name='pricing', ) - pricing_updated = enable_filter( - FilterableDateTimeField( - source='sub_part.pricing_data.updated', allow_null=True, read_only=True - ), - True, + + pricing_updated = OptionalField( + serializer_class=serializers.DateTimeField, + serializer_kwargs={ + 'source': 'sub_part.pricing_data.updated', + 'allow_null': True, + 'read_only': True, + }, + default_include=True, filter_name='pricing', ) @@ -1887,19 +1948,22 @@ class CategoryParameterTemplateSerializer( 'default_value', ] - template_detail = enable_filter( - common.serializers.ParameterTemplateSerializer( - source='template', many=False, read_only=True - ), - True, + template_detail = OptionalField( + serializer_class=common.serializers.ParameterTemplateSerializer, + serializer_kwargs={'source': 'template', 'many': False, 'read_only': True}, + default_include=True, prefetch_fields=['template'], ) - category_detail = enable_filter( - CategorySerializer( - source='category', many=False, read_only=True, allow_null=True - ), - True, + category_detail = OptionalField( + serializer_class=CategorySerializer, + serializer_kwargs={ + 'source': 'category', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, prefetch_fields=['category'], ) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 557d594f14..da8bada994 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -1406,6 +1406,53 @@ class PartAPITest(PartAPITestBase): assert_subset=True, ) + def test_pricing_info(self): + """Test annotation of 'pricing' detail against a Part instance.""" + part = Part.objects.first() + url = reverse('api-part-detail', kwargs={'pk': part.pk}) + + pricing_fields = ['pricing_min', 'pricing_max', 'pricing_updated'] + + for included in [True, False]: + response = self.get(url, {'pricing': included}, expected_code=200) + + for field in pricing_fields: + if included: + self.assertIn(field, response.data) + else: + self.assertNotIn(field, response.data) + + def test_parameters_info(self): + """Test annotation of 'parameters' detail against a Part instance.""" + part = Part.objects.first() + url = reverse('api-part-detail', kwargs={'pk': part.pk}) + + for included in [True, False]: + response = self.get(url, {'parameters': included}, expected_code=200) + + if included: + self.assertIn('parameters', response.data) + else: + self.assertNotIn('parameters', response.data) + + def test_category_detail(self): + """Test annotation of 'category_detail' against a Part instance.""" + part = Part.objects.get(pk=1) + url = reverse('api-part-detail', kwargs={'pk': part.pk}) + + for included in [True, False]: + response = self.get(url, {'category_detail': included}, expected_code=200) + + if not included: + self.assertNotIn('category_detail', response.data) + continue + + self.assertIn('category_detail', response.data) + category = response.data['category_detail'] + + for field in ['name', 'description', 'structural']: + self.assertIn(field, category) + class PartCreationTests(PartAPITestBase): """Tests for creating new Part instances via the API.""" @@ -2707,8 +2754,28 @@ class BomItemTest(InvenTreeAPITestCase): def test_get_bom_detail(self): """Get the detail view for a single BomItem object.""" - url = reverse('api-bom-item-detail', kwargs={'pk': 3}) + from part.models import BomItemSubstitute + bom_item = BomItem.objects.get(pk=3) + + # Create some substitutes for this BomItem + substitute_parts = Part.objects.filter(component=True).exclude( + pk=bom_item.sub_part.pk + )[:3] + + for part in substitute_parts: + BomItemSubstitute.objects.create(bom_item=bom_item, part=part) + + self.assertEqual(bom_item.substitutes.count(), 3) + + url = reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}) + + # First, get without substitutes + response = self.get(url, expected_code=200) + + self.assertNotIn('substitutes', response.data) + + # Now, get with substitutes response = self.get(url, {'substitutes': True}, expected_code=200) expected_values = [ @@ -2735,6 +2802,15 @@ class BomItemTest(InvenTreeAPITestCase): self.assertEqual(int(float(response.data['quantity'])), 25) + # Look at the substitutes data + subs = response.data['substitutes'] + + self.assertEqual(len(subs), 3) + + for sub in subs: + for field in ['pk', 'part', 'bom_item', 'part_detail']: + self.assertIn(field, sub) + # Increase the quantity data = response.data data['quantity'] = 57 diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 5aa339e340..35a5885430 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -33,10 +33,10 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin from importer.registry import register_importer from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( - FilterableListField, InvenTreeCurrencySerializer, InvenTreeDecimalField, - enable_filter, + OptionalField, + TreePathSerializer, ) from users.serializers import UserSerializer @@ -231,8 +231,9 @@ class StockItemTestResultSerializer( self.fields['user'].read_only = True self.fields['date'].read_only = True - user_detail = enable_filter( - UserSerializer(source='user', read_only=True, allow_null=True), + user_detail = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={'source': 'user', 'read_only': True, 'allow_null': True}, prefetch_fields=['user'], ) @@ -245,10 +246,9 @@ class StockItemTestResultSerializer( label=_('Test template for this result'), ) - template_detail = enable_filter( - part_serializers.PartTestTemplateSerializer( - source='template', read_only=True, allow_null=True - ), + template_detail = OptionalField( + serializer_class=part_serializers.PartTestTemplateSerializer, + serializer_kwargs={'source': 'template', 'read_only': True, 'allow_null': True}, prefetch_fields=['template'], ) @@ -377,20 +377,20 @@ class StockItemSerializer( 'purchase_price_currency', 'use_pack_size', 'serial_numbers', - 'tests', # Annotated fields 'allocated', 'expired', 'installed_items', 'child_items', - 'location_path', 'stale', - 'tracking_items', - 'tags', - # Detail fields (FK relationships) - 'supplier_part_detail', - 'part_detail', + # Optional fields (FK relationships) 'location_detail', + 'location_path', + 'part_detail', + 'supplier_part_detail', + 'tags', + 'tests', + 'tracking_items', ] read_only_fields = [ 'allocated', @@ -428,13 +428,16 @@ class StockItemSerializer( help_text=_('Parent stock item'), ) - location_path = enable_filter( - FilterableListField( - child=serializers.DictField(), - source='location.get_path', - read_only=True, - allow_null=True, - ), + location_path = OptionalField( + serializer_class=TreePathSerializer, + serializer_kwargs={ + 'source': 'location.get_path', + 'extra_fields': ['icon'], + 'many': True, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, filter_name='path_detail', ) @@ -577,19 +580,20 @@ class StockItemSerializer( ) # Optional detail fields, which can be appended via query parameters - supplier_part_detail = enable_filter( - company_serializers.SupplierPartSerializer( - label=_('Supplier Part'), - source='supplier_part', - brief=True, - supplier_detail=False, - manufacturer_detail=False, - part_detail=False, - many=False, - read_only=True, - allow_null=True, - ), - False, + supplier_part_detail = OptionalField( + serializer_class=company_serializers.SupplierPartSerializer, + serializer_kwargs={ + 'label': _('Supplier Part'), + 'source': 'supplier_part', + 'brief': True, + 'supplier_detail': False, + 'manufacturer_detail': False, + 'part_detail': False, + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=[ 'supplier_part__supplier', 'supplier_part__purchase_order_line_items', @@ -597,30 +601,40 @@ class StockItemSerializer( ], ) - part_detail = enable_filter( - part_serializers.PartBriefSerializer( - label=_('Part'), source='part', many=False, read_only=True, allow_null=True - ), - True, + part_detail = OptionalField( + serializer_class=part_serializers.PartBriefSerializer, + serializer_kwargs={ + 'label': _('Part'), + 'source': 'part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=True, ) - location_detail = enable_filter( - LocationBriefSerializer( - label=_('Location'), - source='location', - many=False, - read_only=True, - allow_null=True, - ), - False, + location_detail = OptionalField( + serializer_class=LocationBriefSerializer, + serializer_kwargs={ + 'label': _('Location'), + 'source': 'location', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=['location'], ) - tests = enable_filter( - StockItemTestResultSerializer( - source='test_results', many=True, read_only=True, allow_null=True - ), - False, + tests = OptionalField( + serializer_class=StockItemTestResultSerializer, + serializer_kwargs={ + 'source': 'test_results', + 'many': True, + 'read_only': True, + 'allow_null': True, + }, + default_include=False, prefetch_fields=[ 'test_results', 'test_results__user', @@ -1222,13 +1236,16 @@ class LocationSerializer( tags = common.filters.enable_tags_filter() - path = enable_filter( - FilterableListField( - child=serializers.DictField(), - source='get_path', - read_only=True, - allow_null=True, - ), + path = OptionalField( + serializer_class=TreePathSerializer, + serializer_kwargs={ + 'many': True, + 'source': 'get_path', + 'extra_fields': ['icon'], + 'read_only': True, + 'allow_null': True, + }, + default_include=False, filter_name='path_detail', ) @@ -1273,21 +1290,37 @@ class StockTrackingSerializer( label = serializers.CharField(read_only=True) - item_detail = enable_filter( - StockItemSerializer(source='item', many=False, read_only=True, allow_null=True), + item_detail = OptionalField( + serializer_class=StockItemSerializer, + serializer_kwargs={ + 'source': 'item', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['item', 'item__part'], ) - part_detail = enable_filter( - part_serializers.PartBriefSerializer( - source='part', many=False, read_only=True, allow_null=True - ), + part_detail = OptionalField( + serializer_class=part_serializers.PartBriefSerializer, + serializer_kwargs={ + 'source': 'part', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, default_include=False, prefetch_fields=['part'], ) - user_detail = enable_filter( - UserSerializer(source='user', many=False, read_only=True, allow_null=True), + user_detail = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={ + 'source': 'user', + 'many': False, + 'read_only': True, + 'allow_null': True, + }, prefetch_fields=['user'], ) diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 37434fbcff..6e07f35c77 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -256,7 +256,7 @@ class UserList(ListCreateAPI): - Otherwise authenticated users have read-only access """ - queryset = User.objects.all() + queryset = User.objects.all().prefetch_related('groups') serializer_class = UserCreateSerializer # User must have the right role, AND be a staff user, else read-only diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 0f58ebabb9..417a863ae3 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -11,11 +11,9 @@ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied from InvenTree.serializers import ( - FilterableListSerializer, - FilterableSerializerMethodField, FilterableSerializerMixin, InvenTreeModelSerializer, - enable_filter, + OptionalField, ) from .models import ApiToken, Owner, RuleSet, UserProfile @@ -56,7 +54,6 @@ class RuleSetSerializer(InvenTreeModelSerializer): 'can_delete', ] read_only_fields = ['pk', 'name', 'label', 'group'] - list_serializer_class = FilterableListSerializer class RoleSerializer(InvenTreeModelSerializer): @@ -185,7 +182,6 @@ class UserSerializer(InvenTreeModelSerializer): model = User fields = ['pk', 'username', 'first_name', 'last_name', 'email'] read_only_fields = ['username', 'email'] - list_serializer_class = FilterableListSerializer username = serializers.CharField(label=_('Username'), help_text=_('Username')) @@ -267,8 +263,9 @@ class GroupSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): model = Group fields = ['pk', 'name', 'permissions', 'roles', 'users'] - permissions = enable_filter( - FilterableSerializerMethodField(allow_null=True, read_only=True), + permissions = OptionalField( + serializer_class=serializers.SerializerMethodField, + serializer_kwargs={'allow_null': True, 'read_only': True}, filter_name='permission_detail', ) @@ -276,16 +273,26 @@ class GroupSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): """Return a list of permissions associated with the group.""" return generate_permission_dict(group.permissions.all()) - roles = enable_filter( - RuleSetSerializer( - source='rule_sets', many=True, read_only=True, allow_null=True - ), + roles = OptionalField( + serializer_class=RuleSetSerializer, + serializer_kwargs={ + 'source': 'rule_sets', + 'many': True, + 'read_only': True, + 'allow_null': True, + }, filter_name='role_detail', prefetch_fields=['rule_sets'], ) - users = enable_filter( - UserSerializer(source='user_set', many=True, read_only=True, allow_null=True), + users = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={ + 'source': 'user_set', + 'many': True, + 'read_only': True, + 'allow_null': True, + }, filter_name='user_detail', prefetch_fields=['user_set'], ) diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index bf42986c87..c3acd2de0b 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -218,8 +218,11 @@ class UserAPITests(InvenTreeAPITestCase): expected_code=200, ) self.assertIn('name', response.data) + self.assertIn('roles', response.data) self.assertIn('permissions', response.data) + self.assertGreater(len(response.data['roles']), 0) + def test_login_redirect(self): """Test login redirect endpoint.""" response = self.get(reverse('api-login-redirect'), expected_code=302)