2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-21 10:40:52 +00:00

[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 <code@mjmair.com>
This commit is contained in:
Oliver
2026-04-12 10:50:29 +10:00
committed by GitHub
parent 5aaf1cfcab
commit 40b67f5f12
20 changed files with 1263 additions and 956 deletions

View File

@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """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 = """
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 v477 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11617
- Non-functional refactor, adaptations of descriptions - Non-functional refactor, adaptations of descriptions

View File

@@ -380,6 +380,15 @@ class InvenTreeMetadata(SimpleMetadata):
We take the regular DRF metadata and add our own unique flavor 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 # Try to add the child property to the dependent field to be used by the super call
if self.label_lookup[field] == 'dependent field': if self.label_lookup[field] == 'dependent field':
field.get_child(raise_exception=True) field.get_child(raise_exception=True)

View File

@@ -6,7 +6,6 @@ from rest_framework import generics, mixins, status
from rest_framework.response import Response from rest_framework.response import Response
import data_exporter.mixins import data_exporter.mixins
import data_exporter.serializers
import importer.mixins import importer.mixins
from InvenTree.fields import InvenTreeNotesField, OutputConfiguration from InvenTree.fields import InvenTreeNotesField, OutputConfiguration
from InvenTree.helpers import ( from InvenTree.helpers import (
@@ -214,20 +213,6 @@ class OutputOptionsMixin:
if getattr(cls, 'output_options', None) is not None: if getattr(cls, 'output_options', None) is not None:
schema_for_view_output_options(cls) 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): def get_serializer(self, *args, **kwargs):
"""Return serializer instance with output options applied.""" """Return serializer instance with output options applied."""
request = getattr(self, 'request', None) request = getattr(self, 'request', None)
@@ -241,20 +226,7 @@ class OutputOptionsMixin:
context['request'] = request context['request'] = request
kwargs['context'] = context kwargs['context'] = context
serializer = super().get_serializer(*args, **kwargs) return 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
def get_queryset(self): def get_queryset(self):
"""Return the queryset with output options applied. """Return the queryset with output options applied.

View File

@@ -2,8 +2,9 @@
from collections import OrderedDict from collections import OrderedDict
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Any, Optional from typing import Optional
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError 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.fields import empty
from rest_framework.mixins import ListModelMixin from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import SAFE_METHODS 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 rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer, TagListSerializerField from taggit.serializers import TaggitSerializer
import common.models as common_models import common.models as common_models
import InvenTree.ready import InvenTree.ready
@@ -33,94 +34,239 @@ from InvenTree.helpers import str2bool
from InvenTree.helpers_model import getModelsWithMixin from InvenTree.helpers_model import getModelsWithMixin
# region path filtering @dataclass
class FilterableSerializerField: class OptionalField:
"""Mixin to mark serializer as filterable. """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 Adding OptionalField instances to a serializer class is more "efficient"
is_filterable_vals = {} than directly adding the field (and later removing it),
as the field is never instantiated unless it is required.
# Options for automatic queryset prefetching Additionally, you can specify prefetch fields which will be applied
prefetch_fields: Optional[list[str]] = None to the queryset, *only* if the field is included in the final serializer.
def __init__(self, *args, **kwargs): This allows for optimization of database queries based only on the requested data.
"""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)
super().__init__(*args, **kwargs) Example:
class MySerializer(FilterableSerializerMixin, serializers.ModelSerializer):
my_optional_field = OptionalField(
def enable_filter( serializer_class=serializers.CharField,
func: Any, default_include=False,
default_include: bool = False, filter_name='include_my_field',
filter_name: Optional[str] = None, serializer_kwargs={
filter_by_query: bool = True, 'help_text': 'This is an optional field',
prefetch_fields: Optional[list[str]] = None, 'read_only': True,
): },
"""Decorator for marking a serializer field as filterable. prefetch_fields=['related_field'],
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!'
) )
# 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 serializer_class: Serializer
func._kwargs['prefetch_fields'] = prefetch_fields serializer_kwargs: Optional[dict] = None
default_include: bool = False
return func filter_name: Optional[str] = None
filter_by_query: bool = True
prefetch_fields: Optional[list[str]] = None
class FilterableSerializerMixin: class FilterableSerializerMixin:
"""Mixin that enables filtering of marked fields on a serializer. """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. 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 optional_filters: dict = None
no_filters = False fields_to_remove: set = None
"""If True, do not raise an exception if no filterable fields are found.""" optional_fields: set = None
filter_on_query = True filter_on_query: bool = True
"""If True, also look for filter parameters in the request query parameters."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialization routine for the serializer. This gathers and applies filters through kwargs.""" """Initialization routine for the serializer. This gathers and applies filters through kwargs."""
# add list_serializer_class to meta if not present - reduces duplication # Extract some useful context information for later use
if not isinstance(self, FilterableListSerializer) and ( context = kwargs.get('context', {})
not hasattr(self.Meta, 'list_serializer_class') self.request = context.get('request', None) or getattr(self, 'request', None)
): self.request_query_params = (
self.Meta.list_serializer_class = FilterableListSerializer dict(getattr(self.request, 'query_params', {})) if self.request else {}
)
self.gather_optional_fields(kwargs)
self.gather_filters(kwargs)
super().__init__(*args, **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: def prefetch_queryset(self, queryset: QuerySet) -> QuerySet:
"""Apply any prefetching to the queryset based on the optionally included fields. """Apply any prefetching to the queryset based on the optionally included fields.
@@ -140,157 +286,48 @@ class FilterableSerializerMixin:
if getattr(request, '_metadata_requested', False): if getattr(request, '_metadata_requested', False):
return queryset return queryset
# Gather up the set of simple 'prefetch' fields and functions if self.prefetch_list and len(self.prefetch_list) > 0:
prefetch_fields = set() queryset = queryset.prefetch_related(*list(self.prefetch_list))
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))
return queryset 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): class EmptySerializer(serializers.Serializer):
"""Empty serializer for use in testing.""" """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. """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 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): def __init__(self, *args, **kwargs):
@@ -477,7 +514,7 @@ class DependentField(serializers.Field):
return None 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.""" """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation."""
# Switch out URLField mapping # Switch out URLField mapping

View File

@@ -8,6 +8,7 @@ from rest_framework.serializers import SerializerMethodField
import InvenTree.serializers import InvenTree.serializers
from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin
from InvenTree.serializers import OptionalField
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from InvenTree.urls import backendpatterns from InvenTree.urls import backendpatterns
@@ -25,21 +26,25 @@ class SampleSerializer(
fields = ['field_a', 'field_b', 'field_c', 'field_d', 'field_e', 'id'] fields = ['field_a', 'field_b', 'field_c', 'field_d', 'field_e', 'id']
field_a = SerializerMethodField(method_name='sample') field_a = SerializerMethodField(method_name='sample')
field_b = InvenTree.serializers.enable_filter( field_b = OptionalField(
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample') serializer_class=SerializerMethodField,
serializer_kwargs={'method_name': 'sample'},
) )
field_c = InvenTree.serializers.enable_filter( field_c = OptionalField(
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'), serializer_class=SerializerMethodField,
True, serializer_kwargs={'method_name': 'sample'},
default_include=True,
filter_name='crazy_name', filter_name='crazy_name',
) )
field_d = InvenTree.serializers.enable_filter( field_d = OptionalField(
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'), serializer_class=SerializerMethodField,
True, serializer_kwargs={'method_name': 'sample'},
default_include=True,
filter_name='crazy_name', filter_name='crazy_name',
) )
field_e = InvenTree.serializers.enable_filter( field_e = OptionalField(
InvenTree.serializers.FilterableSerializerMethodField(method_name='sample'), serializer_class=SerializerMethodField,
serializer_kwargs={'method_name': 'sample'},
filter_name='field_e', filter_name='field_e',
filter_by_query=False, filter_by_query=False,
) )
@@ -106,110 +111,3 @@ class FilteredSerializers(InvenTreeAPITestCase):
self.assertContains(response, 'field_c') self.assertContains(response, 'field_c')
self.assertContains(response, 'field_d') self.assertContains(response, 'field_d')
self.assertNotContains(response, 'field_e') 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',
)

View File

@@ -35,7 +35,7 @@ from InvenTree.serializers import (
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
NotesFieldMixin, NotesFieldMixin,
enable_filter, OptionalField,
) )
from stock.generators import generate_batch_code from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@@ -116,9 +116,10 @@ class BuildSerializer(
status_text = serializers.CharField(source='get_status_display', read_only=True) status_text = serializers.CharField(source='get_status_display', read_only=True)
part_detail = enable_filter( part_detail = OptionalField(
part_serializers.PartBriefSerializer(source='part', many=False, read_only=True), serializer_class=part_serializers.PartBriefSerializer,
True, serializer_kwargs={'source': 'part', 'many': False, 'read_only': True},
default_include=True,
prefetch_fields=['part', 'part__category', 'part__pricing_data'], prefetch_fields=['part', 'part__category', 'part__pricing_data'],
) )
@@ -132,16 +133,22 @@ class BuildSerializer(
overdue = serializers.BooleanField(read_only=True, default=False) overdue = serializers.BooleanField(read_only=True, default=False)
issued_by_detail = enable_filter( issued_by_detail = OptionalField(
UserSerializer(source='issued_by', read_only=True), serializer_class=UserSerializer,
True, serializer_kwargs={'source': 'issued_by', 'read_only': True},
default_include=True,
filter_name='user_detail', filter_name='user_detail',
prefetch_fields=['issued_by'], prefetch_fields=['issued_by'],
) )
responsible_detail = enable_filter( responsible_detail = OptionalField(
OwnerSerializer(source='responsible', read_only=True, allow_null=True), serializer_class=OwnerSerializer,
True, serializer_kwargs={
'source': 'responsible',
'read_only': True,
'allow_null': True,
},
default_include=True,
filter_name='user_detail', filter_name='user_detail',
prefetch_fields=['responsible'], prefetch_fields=['responsible'],
) )
@@ -1200,31 +1207,33 @@ class BuildItemSerializer(
) )
# Extra (optional) detail fields # Extra (optional) detail fields
part_detail = enable_filter( part_detail = OptionalField(
part_serializers.PartBriefSerializer( serializer_class=part_serializers.PartBriefSerializer,
label=_('Part'), serializer_kwargs={
source='stock_item.part', 'label': _('Part'),
many=False, 'source': 'stock_item.part',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
pricing=False, 'allow_null': True,
), 'pricing': False,
True, },
default_include=True,
prefetch_fields=['stock_item__part'], prefetch_fields=['stock_item__part'],
) )
stock_item_detail = enable_filter( stock_item_detail = OptionalField(
StockItemSerializer( serializer_class=StockItemSerializer,
source='stock_item', serializer_kwargs={
read_only=True, 'source': 'stock_item',
allow_null=True, 'read_only': True,
label=_('Stock Item'), 'allow_null': True,
part_detail=False, 'label': _('Stock Item'),
location_detail=False, 'part_detail': False,
supplier_part_detail=False, 'location_detail': False,
path_detail=False, 'supplier_part_detail': False,
), 'path_detail': False,
True, },
default_include=True,
filter_name='stock_detail', filter_name='stock_detail',
prefetch_fields=[ prefetch_fields=[
'stock_item', 'stock_item',
@@ -1234,18 +1243,19 @@ class BuildItemSerializer(
], ],
) )
install_into_detail = enable_filter( install_into_detail = OptionalField(
StockItemSerializer( serializer_class=StockItemSerializer,
source='install_into', serializer_kwargs={
read_only=True, 'source': 'install_into',
allow_null=True, 'read_only': True,
label=_('Install Into'), 'allow_null': True,
part_detail=False, 'label': _('Install Into'),
location_detail=False, 'part_detail': False,
supplier_part_detail=False, 'location_detail': False,
path_detail=False, 'supplier_part_detail': False,
), 'path_detail': False,
False, },
default_include=False,
prefetch_fields=['install_into', 'install_into__part'], prefetch_fields=['install_into', 'install_into__part'],
) )
@@ -1253,26 +1263,28 @@ class BuildItemSerializer(
label=_('Location'), source='stock_item.location', many=False, read_only=True label=_('Location'), source='stock_item.location', many=False, read_only=True
) )
location_detail = enable_filter( location_detail = OptionalField(
LocationBriefSerializer( serializer_class=LocationBriefSerializer,
label=_('Location'), serializer_kwargs={
source='stock_item.location', 'label': _('Location'),
read_only=True, 'source': 'stock_item.location',
allow_null=True, 'read_only': True,
), 'allow_null': True,
True, },
default_include=True,
prefetch_fields=['stock_item__location'], prefetch_fields=['stock_item__location'],
) )
build_detail = enable_filter( build_detail = OptionalField(
BuildSerializer( serializer_class=BuildSerializer,
label=_('Build'), serializer_kwargs={
source='build_line.build', 'label': _('Build'),
many=False, 'source': 'build_line.build',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
True, },
default_include=True,
prefetch_fields=[ prefetch_fields=[
'build_line__build', 'build_line__build',
'build_line__build__part', 'build_line__build__part',
@@ -1283,16 +1295,17 @@ class BuildItemSerializer(
], ],
) )
supplier_part_detail = enable_filter( supplier_part_detail = OptionalField(
company.serializers.SupplierPartSerializer( serializer_class=company.serializers.SupplierPartSerializer,
label=_('Supplier Part'), serializer_kwargs={
source='stock_item.supplier_part', 'label': _('Supplier Part'),
many=False, 'source': 'stock_item.supplier_part',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
brief=True, 'allow_null': True,
), 'brief': True,
False, },
default_include=False,
prefetch_fields=[ prefetch_fields=[
'stock_item__supplier_part', 'stock_item__supplier_part',
'stock_item__supplier_part__supplier', 'stock_item__supplier_part__supplier',
@@ -1382,11 +1395,15 @@ class BuildLineSerializer(
read_only=True, read_only=True,
) )
allocations = enable_filter( allocations = OptionalField(
BuildItemSerializer( serializer_class=BuildItemSerializer,
many=True, read_only=True, allow_null=True, build_detail=False serializer_kwargs={
), 'many': True,
True, 'read_only': True,
'allow_null': True,
'build_detail': False,
},
default_include=True,
prefetch_fields=[ prefetch_fields=[
'allocations', 'allocations',
'allocations__stock_item', 'allocations__stock_item',
@@ -1427,73 +1444,79 @@ class BuildLineSerializer(
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True) bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
# Foreign key fields # Foreign key fields
bom_item_detail = enable_filter( bom_item_detail = OptionalField(
part_serializers.BomItemSerializer( serializer_class=part_serializers.BomItemSerializer,
label=_('BOM Item'), serializer_kwargs={
source='bom_item', 'label': _('BOM Item'),
many=False, 'source': 'bom_item',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
pricing=False, 'allow_null': True,
substitutes=False, 'pricing': False,
sub_part_detail=False, 'substitutes': False,
part_detail=False, 'sub_part_detail': False,
can_build=False, 'part_detail': False,
), 'can_build': False,
False, },
default_include=False,
prefetch_fields=['bom_item'], prefetch_fields=['bom_item'],
) )
assembly_detail = enable_filter( assembly_detail = OptionalField(
part_serializers.PartBriefSerializer( serializer_class=part_serializers.PartBriefSerializer,
label=_('Assembly'), serializer_kwargs={
source='bom_item.part', 'label': _('Assembly'),
many=False, 'source': 'bom_item.part',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
pricing=False, 'allow_null': True,
), 'pricing': False,
False, },
default_include=False,
prefetch_fields=['bom_item__part', 'bom_item__part__pricing_data'], prefetch_fields=['bom_item__part', 'bom_item__part__pricing_data'],
) )
part_detail = enable_filter( part_detail = OptionalField(
part_serializers.PartBriefSerializer( serializer_class=part_serializers.PartBriefSerializer,
label=_('Part'), serializer_kwargs={
source='bom_item.sub_part', 'label': _('Part'),
many=False, 'source': 'bom_item.sub_part',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
pricing=False, 'allow_null': True,
), 'pricing': False,
False, },
default_include=False,
prefetch_fields=['bom_item__sub_part', 'bom_item__sub_part__pricing_data'], prefetch_fields=['bom_item__sub_part', 'bom_item__sub_part__pricing_data'],
) )
category_detail = enable_filter( category_detail = OptionalField(
part_serializers.CategorySerializer( serializer_class=part_serializers.CategorySerializer,
label=_('Category'), serializer_kwargs={
source='bom_item.sub_part.category', 'label': _('Category'),
many=False, 'source': 'bom_item.sub_part.category',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
False, 'path_detail': False,
},
default_include=False,
prefetch_fields=['bom_item__sub_part__category'], prefetch_fields=['bom_item__sub_part__category'],
) )
build_detail = enable_filter( build_detail = OptionalField(
BuildSerializer( serializer_class=BuildSerializer,
label=_('Build'), serializer_kwargs={
source='build', 'label': _('Build'),
many=False, 'source': 'build',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
part_detail=False, 'allow_null': True,
user_detail=False, 'part_detail': False,
project_code_detail=False, 'user_detail': False,
), 'project_code_detail': False,
True, },
default_include=True,
) )
# Annotated (calculated) fields # Annotated (calculated) fields

View File

@@ -1162,18 +1162,12 @@ class BuildListTest(BuildAPITest):
data = self.options(self.url, expected_code=200).data data = self.options(self.url, expected_code=200).data
self.assertEqual(data['name'], 'Build List') self.assertEqual(data['name'], 'Build List')
actions = data['actions']['POST'] actions = data['actions']['GET']
for field_name in [ for field_name in ['pk', 'title', 'part', 'project_code', 'quantity']:
'pk', # Fields should exist in both GET and POST actions
'title',
'part',
'part_detail',
'project_code',
'project_code_detail',
'quantity',
]:
self.assertIn(field_name, actions) self.assertIn(field_name, actions)
self.assertIn(field_name, data['actions']['POST'])
# Specific checks for certain fields # Specific checks for certain fields
for field_name in ['part', 'project_code', 'take_from']: for field_name in ['part', 'project_code', 'take_from']:

View File

@@ -19,6 +19,9 @@ from django.db.models import (
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from taggit.serializers import TagListSerializerField
import InvenTree.conversion import InvenTree.conversion
import InvenTree.helpers import InvenTree.helpers
import InvenTree.serializers import InvenTree.serializers
@@ -325,11 +328,15 @@ def enable_project_code_filter(default: bool = True):
""" """
from common.serializers import ProjectCodeSerializer from common.serializers import ProjectCodeSerializer
return InvenTree.serializers.enable_filter( return InvenTree.serializers.OptionalField(
ProjectCodeSerializer( serializer_class=ProjectCodeSerializer,
source='project_code', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'project_code',
default, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=default,
filter_name='project_code_detail', filter_name='project_code_detail',
prefetch_fields=['project_code'], 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. If applied, this field will automatically prefetch the 'project_code' relationship.
""" """
return InvenTree.serializers.enable_filter( return InvenTree.serializers.OptionalField(
InvenTree.serializers.FilterableCharField( serializer_class=serializers.CharField,
source='project_code.code', serializer_kwargs={
read_only=True, 'source': 'project_code.code',
label=_('Project Code Label'), 'read_only': True,
allow_null=True, 'label': _('Project Code Label'),
), 'allow_null': True,
default, },
default_include=default,
filter_name='project_code_detail', filter_name='project_code_detail',
prefetch_fields=['project_code'], prefetch_fields=['project_code'],
) )
@@ -369,9 +377,15 @@ def enable_parameters_filter():
""" """
from common.serializers import ParameterSerializer from common.serializers import ParameterSerializer
return InvenTree.serializers.enable_filter( return InvenTree.serializers.OptionalField(
ParameterSerializer(many=True, read_only=True, allow_null=True), serializer_class=ParameterSerializer,
False, serializer_kwargs={
'many': True,
'read_only': True,
'allow_null': True,
'required': False,
},
default_include=False,
filter_name='parameters', filter_name='parameters',
prefetch_fields=[ prefetch_fields=[
'parameters_list', 'parameters_list',
@@ -390,11 +404,10 @@ def enable_tags_filter(default: bool = False):
If applied, this field will automatically prefetch the 'tags' relationship. If applied, this field will automatically prefetch the 'tags' relationship.
""" """
from InvenTree.serializers import FilterableTagListField return InvenTree.serializers.OptionalField(
serializer_class=TagListSerializerField,
return InvenTree.serializers.enable_filter( serializer_kwargs={'required': False},
FilterableTagListField(required=False), default_include=default,
default,
filter_name='tags', filter_name='tags',
prefetch_fields=['tags', 'tagged_items', 'tagged_items__tag'], prefetch_fields=['tags', 'tagged_items', 'tagged_items__tag'],
) )

View File

@@ -28,7 +28,7 @@ from InvenTree.serializers import (
InvenTreeAttachmentSerializerField, InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField, InvenTreeImageSerializerField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
enable_filter, OptionalField,
) )
from plugin import registry as plugin_registry from plugin import registry as plugin_registry
from users.serializers import OwnerSerializer, UserSerializer from users.serializers import OwnerSerializer, UserSerializer
@@ -857,6 +857,7 @@ class ParameterSerializer(
'note', 'note',
'updated', 'updated',
'updated_by', 'updated_by',
# Optional fields
'template_detail', 'template_detail',
'updated_by_detail', 'updated_by_detail',
] ]
@@ -906,17 +907,22 @@ class ParameterSerializer(
allow_null=False, allow_null=False,
) )
updated_by_detail = enable_filter( updated_by_detail = OptionalField(
UserSerializer( serializer_class=UserSerializer,
source='updated_by', read_only=True, allow_null=True, many=False serializer_kwargs={
), 'source': 'updated_by',
True, 'read_only': True,
'allow_null': True,
'many': False,
},
default_include=True,
prefetch_fields=['updated_by'], prefetch_fields=['updated_by'],
) )
template_detail = enable_filter( template_detail = OptionalField(
ParameterTemplateSerializer(source='template', read_only=True, many=False), serializer_class=ParameterTemplateSerializer,
True, serializer_kwargs={'source': 'template', 'read_only': True, 'many': False},
default_include=True,
prefetch_fields=['template', 'template__model_type'], prefetch_fields=['template', 'template__model_type'],
) )

View File

@@ -179,6 +179,7 @@ class ManufacturerPartMixin(SerializerContextMixin):
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('supplier_parts') queryset = queryset.prefetch_related('supplier_parts')
queryset = queryset.prefetch_related('part', 'part__pricing_data')
return queryset return queryset

View File

@@ -17,7 +17,6 @@ from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import ( from InvenTree.serializers import (
FilterableCharField,
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
@@ -26,8 +25,8 @@ from InvenTree.serializers import (
InvenTreeMoneySerializer, InvenTreeMoneySerializer,
InvenTreeTagModelSerializer, InvenTreeTagModelSerializer,
NotesFieldMixin, NotesFieldMixin,
OptionalField,
RemoteImageMixin, RemoteImageMixin,
enable_filter,
) )
from .models import ( from .models import (
@@ -164,9 +163,10 @@ class CompanySerializer(
return queryset return queryset
primary_address = enable_filter( primary_address = OptionalField(
AddressBriefSerializer(read_only=True, allow_null=True), serializer_class=AddressBriefSerializer,
False, serializer_kwargs={'read_only': True, 'many': False, 'allow_null': True},
default_include=False,
filter_name='address_detail', filter_name='address_detail',
prefetch_fields=[ prefetch_fields=[
Prefetch( Prefetch(
@@ -263,27 +263,37 @@ class ManufacturerPartSerializer(
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
part_detail = enable_filter( part_detail = OptionalField(
part_serializers.PartBriefSerializer( serializer_class=part_serializers.PartBriefSerializer,
source='part', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'part',
True, 'many': False,
prefetch_fields=['part', 'part__pricing_data', 'part__category'], 'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['part__category'],
) )
pretty_name = enable_filter( pretty_name = OptionalField(
FilterableCharField(read_only=True, allow_null=True), filter_name='pretty' serializer_class=serializers.CharField,
serializer_kwargs={'read_only': True, 'allow_null': True},
filter_name='pretty',
) )
manufacturer = serializers.PrimaryKeyRelatedField( manufacturer = serializers.PrimaryKeyRelatedField(
queryset=Company.objects.filter(is_manufacturer=True) queryset=Company.objects.filter(is_manufacturer=True)
) )
manufacturer_detail = enable_filter( manufacturer_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
source='manufacturer', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'manufacturer',
True, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['manufacturer'], prefetch_fields=['manufacturer'],
) )
@@ -415,70 +425,86 @@ class SupplierPartSerializer(
pack_quantity_native = serializers.FloatField(read_only=True) pack_quantity_native = serializers.FloatField(read_only=True)
price_breaks = enable_filter( price_breaks = OptionalField(
SupplierPriceBreakBriefSerializer( serializer_class=SupplierPriceBreakBriefSerializer,
source='pricebreaks', serializer_kwargs={
many=True, 'source': 'pricebreaks',
read_only=True, 'many': True,
allow_null=True, 'read_only': True,
label=_('Price Breaks'), 'allow_null': True,
), 'label': _('Price Breaks'),
False, },
default_include=False,
filter_name='price_breaks', filter_name='price_breaks',
prefetch_fields=['pricebreaks'], prefetch_fields=['pricebreaks'],
) )
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
part_detail = enable_filter( part_detail = OptionalField(
part_serializers.PartBriefSerializer( serializer_class=part_serializers.PartBriefSerializer,
label=_('Part'), source='part', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'label': _('Part'),
False, 'source': 'part',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['part', 'part__pricing_data'], prefetch_fields=['part', 'part__pricing_data'],
) )
supplier_detail = enable_filter( supplier_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
label=_('Supplier'), serializer_kwargs={
source='supplier', 'label': _('Supplier'),
many=False, 'source': 'supplier',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
False, },
default_include=False,
prefetch_fields=['supplier'], prefetch_fields=['supplier'],
) )
manufacturer_detail = enable_filter( manufacturer_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
label=_('Manufacturer'), serializer_kwargs={
source='manufacturer_part.manufacturer', 'label': _('Manufacturer'),
many=False, 'source': 'manufacturer_part.manufacturer',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
False, },
default_include=False,
prefetch_fields=['manufacturer_part__manufacturer'], prefetch_fields=['manufacturer_part__manufacturer'],
) )
pretty_name = enable_filter( pretty_name = OptionalField(
FilterableCharField(read_only=True, allow_null=True), filter_name='pretty' serializer_class=serializers.CharField,
serializer_kwargs={
'read_only': True,
'allow_null': True,
'label': _('Pretty Name'),
},
default_include=False,
filter_name='pretty',
) )
supplier = serializers.PrimaryKeyRelatedField( supplier = serializers.PrimaryKeyRelatedField(
label=_('Supplier'), queryset=Company.objects.filter(is_supplier=True) label=_('Supplier'), queryset=Company.objects.filter(is_supplier=True)
) )
manufacturer_part_detail = enable_filter( manufacturer_part_detail = OptionalField(
ManufacturerPartSerializer( serializer_class=ManufacturerPartSerializer,
label=_('Manufacturer Part'), serializer_kwargs={
source='manufacturer_part', 'label': _('Manufacturer Part'),
part_detail=False, 'source': 'manufacturer_part',
read_only=True, 'part_detail': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
False, },
default_include=False,
prefetch_fields=['manufacturer_part'], prefetch_fields=['manufacturer_part'],
) )
@@ -568,18 +594,27 @@ class SupplierPriceBreakSerializer(
return queryset return queryset
supplier_detail = enable_filter( supplier_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
source='part.supplier', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'part.supplier',
False, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['part__supplier'], prefetch_fields=['part__supplier'],
) )
part_detail = enable_filter( part_detail = OptionalField(
SupplierPartSerializer( serializer_class=SupplierPartSerializer,
source='part', brief=True, many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'part',
False, 'brief': True,
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['part', 'part__part', 'part__part__pricing_data'], prefetch_fields=['part', 'part__part', 'part__part__pricing_data'],
) )

View File

@@ -507,6 +507,8 @@ class ManufacturerTest(InvenTreeAPITestCase):
"""Tests for the ManufacturerPart detail endpoint.""" """Tests for the ManufacturerPart detail endpoint."""
mp = ManufacturerPart.objects.first() mp = ManufacturerPart.objects.first()
self.assertIsNotNone(mp)
url = reverse('api-manufacturer-part-detail', kwargs={'pk': mp.pk}) url = reverse('api-manufacturer-part-detail', kwargs={'pk': mp.pk})
response = self.get(url) response = self.get(url)

View File

@@ -394,7 +394,14 @@ class DataImportSession(models.Model):
if serializer_class := self.serializer_class: if serializer_class := self.serializer_class:
serializer = serializer_class(data={}, importing=True) 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 # Cache the available fields against this instance
self._available_fields = fields self._available_fields = fields

View File

@@ -36,7 +36,7 @@ from InvenTree.serializers import (
InvenTreeModelSerializer, InvenTreeModelSerializer,
InvenTreeMoneySerializer, InvenTreeMoneySerializer,
NotesFieldMixin, NotesFieldMixin,
enable_filter, OptionalField,
) )
from order.status_codes import ( from order.status_codes import (
PurchaseOrderStatusGroups, PurchaseOrderStatusGroups,
@@ -126,20 +126,28 @@ class AbstractOrderSerializer(
reference = serializers.CharField(required=True) reference = serializers.CharField(required=True)
# Detail for point-of-contact field # Detail for point-of-contact field
contact_detail = enable_filter( contact_detail = OptionalField(
ContactSerializer( serializer_class=ContactSerializer,
source='contact', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'contact',
True, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['contact'], prefetch_fields=['contact'],
) )
# Detail for responsible field # Detail for responsible field
responsible_detail = enable_filter( responsible_detail = OptionalField(
OwnerSerializer( serializer_class=OwnerSerializer,
source='responsible', read_only=True, allow_null=True, many=False serializer_kwargs={
), 'source': 'responsible',
True, 'read_only': True,
'allow_null': True,
'many': False,
},
default_include=True,
prefetch_fields=['responsible'], prefetch_fields=['responsible'],
) )
@@ -147,11 +155,15 @@ class AbstractOrderSerializer(
project_code_detail = common.filters.enable_project_code_filter() project_code_detail = common.filters.enable_project_code_filter()
# Detail for address field # Detail for address field
address_detail = enable_filter( address_detail = OptionalField(
AddressBriefSerializer( serializer_class=AddressBriefSerializer,
source='address', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'address',
True, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['address'], prefetch_fields=['address'],
) )
@@ -432,10 +444,14 @@ class PurchaseOrderSerializer(
source='supplier.name', read_only=True, label=_('Supplier Name') source='supplier.name', read_only=True, label=_('Supplier Name')
) )
supplier_detail = enable_filter( supplier_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
source='supplier', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'supplier',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['supplier'], prefetch_fields=['supplier'],
) )
@@ -629,19 +645,28 @@ class PurchaseOrderLineItemSerializer(
total_price = serializers.FloatField(read_only=True) total_price = serializers.FloatField(read_only=True)
part_detail = enable_filter( part_detail = OptionalField(
PartBriefSerializer( serializer_class=PartBriefSerializer,
source='get_base_part', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'get_base_part',
False, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
filter_name='part_detail', filter_name='part_detail',
) )
supplier_part_detail = enable_filter( supplier_part_detail = OptionalField(
SupplierPartSerializer( serializer_class=SupplierPartSerializer,
source='part', brief=True, many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'part',
False, 'brief': True,
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
filter_name='part_detail', filter_name='part_detail',
) )
@@ -655,11 +680,14 @@ class PurchaseOrderLineItemSerializer(
default=False, default=False,
) )
destination_detail = enable_filter( destination_detail = OptionalField(
stock.serializers.LocationBriefSerializer( serializer_class=stock.serializers.LocationBriefSerializer,
source='get_destination', read_only=True, allow_null=True serializer_kwargs={
), 'source': 'get_destination',
True, 'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['destination', 'order__destination'], prefetch_fields=['destination', 'order__destination'],
) )
@@ -667,17 +695,26 @@ class PurchaseOrderLineItemSerializer(
help_text=_('Purchase price currency') help_text=_('Purchase price currency')
) )
order_detail = enable_filter( order_detail = OptionalField(
PurchaseOrderSerializer( serializer_class=PurchaseOrderSerializer,
source='order', read_only=True, allow_null=True, many=False serializer_kwargs={
) 'source': 'order',
'read_only': True,
'allow_null': True,
'many': False,
},
default_include=True,
) )
build_order_detail = enable_filter( build_order_detail = OptionalField(
build.serializers.BuildSerializer( serializer_class=build.serializers.BuildSerializer,
source='build_order', read_only=True, allow_null=True, many=False serializer_kwargs={
), 'source': 'build_order',
True, 'read_only': True,
'allow_null': True,
'many': False,
},
default_include=True,
prefetch_fields=[ prefetch_fields=[
'build_order__responsible', 'build_order__responsible',
'build_order__issued_by', 'build_order__issued_by',
@@ -763,10 +800,14 @@ class PurchaseOrderExtraLineSerializer(
model = order.models.PurchaseOrderExtraLine model = order.models.PurchaseOrderExtraLine
fields = AbstractExtraLineSerializer.extra_line_fields([]) fields = AbstractExtraLineSerializer.extra_line_fields([])
order_detail = enable_filter( order_detail = OptionalField(
PurchaseOrderSerializer( serializer_class=PurchaseOrderSerializer,
source='order', many=False, read_only=True, allow_null=True serializer_kwargs={
) 'source': 'order',
'many': False,
'read_only': True,
'allow_null': True,
},
) )
@@ -1098,10 +1139,14 @@ class SalesOrderSerializer(
return queryset return queryset
customer_detail = enable_filter( customer_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
source='customer', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'customer',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['customer'], prefetch_fields=['customer'],
) )
@@ -1252,10 +1297,14 @@ class SalesOrderLineItemSerializer(
return queryset return queryset
order_detail = enable_filter( order_detail = OptionalField(
SalesOrderSerializer( serializer_class=SalesOrderSerializer,
source='order', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'order',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=[ prefetch_fields=[
'order__created_by', 'order__created_by',
'order__responsible', 'order__responsible',
@@ -1265,15 +1314,25 @@ class SalesOrderLineItemSerializer(
], ],
) )
part_detail = enable_filter( part_detail = OptionalField(
PartBriefSerializer(source='part', many=False, read_only=True, allow_null=True), serializer_class=PartBriefSerializer,
serializer_kwargs={
'source': 'part',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['part__pricing_data'], prefetch_fields=['part__pricing_data'],
) )
customer_detail = enable_filter( customer_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
source='order.customer', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'order.customer',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['order__customer'], prefetch_fields=['order__customer'],
) )
@@ -1343,19 +1402,27 @@ class SalesOrderShipmentSerializer(
read_only=True, allow_null=True, label=_('Allocated Items') read_only=True, allow_null=True, label=_('Allocated Items')
) )
checked_by_detail = enable_filter( checked_by_detail = OptionalField(
UserSerializer( serializer_class=UserSerializer,
source='checked_by', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'checked_by',
True, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['checked_by'], prefetch_fields=['checked_by'],
) )
order_detail = enable_filter( order_detail = OptionalField(
SalesOrderSerializer( serializer_class=SalesOrderSerializer,
source='order', read_only=True, allow_null=True, many=False serializer_kwargs={
), 'source': 'order',
True, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=[ prefetch_fields=[
'order', 'order',
'order__customer', 'order__customer',
@@ -1365,19 +1432,27 @@ class SalesOrderShipmentSerializer(
], ],
) )
customer_detail = enable_filter( customer_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
source='order.customer', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'order.customer',
False, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=['order__customer'], prefetch_fields=['order__customer'],
) )
shipment_address_detail = enable_filter( shipment_address_detail = OptionalField(
AddressBriefSerializer( serializer_class=AddressBriefSerializer,
source='shipment_address', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'shipment_address',
True, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['shipment_address'], prefetch_fields=['shipment_address'],
) )
@@ -1428,46 +1503,71 @@ class SalesOrderAllocationSerializer(
) )
# Extra detail fields # Extra detail fields
order_detail = enable_filter( order_detail = OptionalField(
SalesOrderSerializer( serializer_class=SalesOrderSerializer,
source='line.order', many=False, read_only=True, allow_null=True serializer_kwargs={
) 'source': 'line.order',
) 'many': False,
part_detail = enable_filter( 'read_only': True,
PartBriefSerializer( 'allow_null': True,
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
)
) )
shipment_detail = SalesOrderShipmentSerializer( part_detail = OptionalField(
source='shipment', serializer_class=PartBriefSerializer,
order_detail=False, serializer_kwargs={
many=False, 'source': 'item.part',
read_only=True, 'many': False,
allow_null=True, '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 model = order.models.SalesOrderExtraLine
fields = AbstractExtraLineSerializer.extra_line_fields([]) fields = AbstractExtraLineSerializer.extra_line_fields([])
order_detail = enable_filter( order_detail = OptionalField(
SalesOrderSerializer( serializer_class=SalesOrderSerializer,
source='order', many=False, read_only=True, allow_null=True serializer_kwargs={
) 'source': 'order',
'many': False,
'read_only': True,
'allow_null': True,
},
) )
@@ -1942,10 +2046,14 @@ class ReturnOrderSerializer(
return queryset return queryset
customer_detail = enable_filter( customer_detail = OptionalField(
CompanyBriefSerializer( serializer_class=CompanyBriefSerializer,
source='customer', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'customer',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['customer'], prefetch_fields=['customer'],
) )
@@ -2103,10 +2211,14 @@ class ReturnOrderLineItemSerializer(
'part_detail', 'part_detail',
]) ])
order_detail = enable_filter( order_detail = OptionalField(
ReturnOrderSerializer( serializer_class=ReturnOrderSerializer,
source='order', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'order',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=[ prefetch_fields=[
'order__created_by', 'order__created_by',
'order__responsible', 'order__responsible',
@@ -2120,17 +2232,25 @@ class ReturnOrderLineItemSerializer(
label=_('Quantity'), help_text=_('Quantity to return') label=_('Quantity'), help_text=_('Quantity to return')
) )
item_detail = enable_filter( item_detail = OptionalField(
stock.serializers.StockItemSerializer( serializer_class=stock.serializers.StockItemSerializer,
source='item', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'item',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['item__supplier_part'], prefetch_fields=['item__supplier_part'],
) )
part_detail = enable_filter( part_detail = OptionalField(
PartBriefSerializer( serializer_class=PartBriefSerializer,
source='item.part', many=False, read_only=True, allow_null=True serializer_kwargs={
) 'source': 'item.part',
'many': False,
'read_only': True,
'allow_null': True,
},
) )
price = InvenTreeMoneySerializer(allow_null=True) price = InvenTreeMoneySerializer(allow_null=True)
@@ -2149,8 +2269,12 @@ class ReturnOrderExtraLineSerializer(
model = order.models.ReturnOrderExtraLine model = order.models.ReturnOrderExtraLine
fields = AbstractExtraLineSerializer.extra_line_fields([]) fields = AbstractExtraLineSerializer.extra_line_fields([])
order_detail = enable_filter( order_detail = OptionalField(
ReturnOrderSerializer( serializer_class=ReturnOrderSerializer,
source='order', many=False, read_only=True, allow_null=True serializer_kwargs={
) 'source': 'order',
'many': False,
'read_only': True,
'allow_null': True,
},
) )

View File

@@ -35,13 +35,7 @@ from data_exporter.mixins import DataExportSerializerMixin
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import ( from InvenTree.serializers import OptionalField, TreePathSerializer
FilterableDateTimeField,
FilterableFloatField,
FilterableListField,
FilterableListSerializer,
enable_filter,
)
from users.serializers import UserSerializer from users.serializers import UserSerializer
from .models import ( from .models import (
@@ -141,13 +135,15 @@ class CategorySerializer(
return category.pk in self.starred_categories return category.pk in self.starred_categories
path = enable_filter( path = OptionalField(
FilterableListField( serializer_class=TreePathSerializer,
child=serializers.DictField(), serializer_kwargs={
source='get_path', 'source': 'get_path',
read_only=True, 'many': True,
allow_null=True, 'read_only': True,
), 'allow_null': True,
},
default_include=False,
filter_name='path_detail', filter_name='path_detail',
) )
@@ -354,18 +350,25 @@ class PartBriefSerializer(
) )
# Pricing fields # Pricing fields
pricing_min = enable_filter( pricing_min = OptionalField(
InvenTree.serializers.InvenTreeMoneySerializer( serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
source='pricing_data.overall_min', allow_null=True, read_only=True serializer_kwargs={
), 'source': 'pricing_data.overall_min',
True, 'allow_null': True,
'read_only': True,
},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
pricing_max = enable_filter(
InvenTree.serializers.InvenTreeMoneySerializer( pricing_max = OptionalField(
source='pricing_data.overall_max', allow_null=True, read_only=True serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
), serializer_kwargs={
True, 'source': 'pricing_data.overall_max',
'allow_null': True,
'read_only': True,
},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
@@ -774,28 +777,37 @@ class PartSerializer(
return part.pk in self.starred_parts return part.pk in self.starred_parts
# Extra detail for the category # Extra detail for the category
category_detail = enable_filter( category_detail = OptionalField(
CategorySerializer( serializer_class=CategorySerializer,
source='category', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'category',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['category'], prefetch_fields=['category'],
) )
category_path = enable_filter( category_path = OptionalField(
FilterableListField( serializer_class=TreePathSerializer,
child=serializers.DictField(), serializer_kwargs={
source='category.get_path', 'source': 'category.get_path',
read_only=True, 'many': True,
allow_null=True, 'read_only': True,
), 'allow_null': True,
},
filter_name='path_detail', filter_name='path_detail',
prefetch_fields=['category'], prefetch_fields=['category'],
) )
default_location_detail = enable_filter( default_location_detail = OptionalField(
DefaultLocationSerializer( serializer_class=DefaultLocationSerializer,
source='default_location', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'default_location',
'many': False,
'read_only': True,
'allow_null': True,
},
filter_name='location_detail', filter_name='location_detail',
prefetch_fields=['default_location'], prefetch_fields=['default_location'],
) )
@@ -901,25 +913,36 @@ class PartSerializer(
) )
# Pricing fields # Pricing fields
pricing_min = enable_filter( pricing_min = OptionalField(
InvenTree.serializers.InvenTreeMoneySerializer( serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
source='pricing_data.overall_min', allow_null=True, read_only=True serializer_kwargs={
), 'source': 'pricing_data.overall_min',
True, 'allow_null': True,
'read_only': True,
},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
pricing_max = enable_filter(
InvenTree.serializers.InvenTreeMoneySerializer( pricing_max = OptionalField(
source='pricing_data.overall_max', allow_null=True, read_only=True serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
), serializer_kwargs={
True, 'source': 'pricing_data.overall_max',
'allow_null': True,
'read_only': True,
},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
pricing_updated = enable_filter(
FilterableDateTimeField( pricing_updated = OptionalField(
source='pricing_data.updated', allow_null=True, read_only=True serializer_class=serializers.DateTimeField,
), serializer_kwargs={
True, 'source': 'pricing_data.updated',
'allow_null': True,
'read_only': True,
},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
@@ -927,11 +950,15 @@ class PartSerializer(
tags = common.filters.enable_tags_filter() tags = common.filters.enable_tags_filter()
price_breaks = enable_filter( price_breaks = OptionalField(
PartSalePriceSerializer( serializer_class=PartSalePriceSerializer,
source='salepricebreaks', many=True, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'salepricebreaks',
False, 'many': True,
'read_only': True,
'allow_null': True,
},
default_include=False,
filter_name='price_breaks', filter_name='price_breaks',
prefetch_fields=['salepricebreaks'], prefetch_fields=['salepricebreaks'],
) )
@@ -1267,10 +1294,15 @@ class PartStocktakeSerializer(
label=_('Part Description'), label=_('Part Description'),
) )
part_detail = enable_filter( part_detail = OptionalField(
PartBriefSerializer( serializer_class=PartBriefSerializer,
source='part', read_only=True, allow_null=True, many=False, pricing=False serializer_kwargs={
), 'source': 'part',
'read_only': True,
'allow_null': True,
'many': False,
'pricing': False,
},
default_include=False, default_include=False,
) )
@@ -1595,7 +1627,7 @@ class BomItemSubstituteSerializer(InvenTree.serializers.InvenTreeModelSerializer
model = BomItemSubstitute model = BomItemSubstitute
fields = ['pk', 'bom_item', 'part', 'part_detail'] fields = ['pk', 'bom_item', 'part', 'part_detail']
list_serializer_class = FilterableListSerializer # list_serializer_class = FilterableListSerializer
part_detail = PartBriefSerializer( part_detail = PartBriefSerializer(
source='part', read_only=True, many=False, pricing=False source='part', read_only=True, many=False, pricing=False
@@ -1697,9 +1729,15 @@ class BomItemSerializer(
help_text=_('Select the parent assembly'), help_text=_('Select the parent assembly'),
) )
substitutes = enable_filter( substitutes = OptionalField(
BomItemSubstituteSerializer(many=True, read_only=True, allow_null=True), serializer_class=BomItemSubstituteSerializer,
False, serializer_kwargs={
'many': True,
'read_only': True,
'allow_null': True,
'required': False,
},
default_include=False,
filter_name='substitutes', filter_name='substitutes',
prefetch_fields=[ prefetch_fields=[
'substitutes', 'substitutes',
@@ -1709,14 +1747,15 @@ class BomItemSerializer(
], ],
) )
part_detail = enable_filter( part_detail = OptionalField(
PartBriefSerializer( serializer_class=PartBriefSerializer,
source='part', serializer_kwargs={
label=_('Assembly'), 'source': 'part',
many=False, 'label': _('Assembly'),
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
) 'allow_null': True,
},
) )
sub_part = serializers.PrimaryKeyRelatedField( sub_part = serializers.PrimaryKeyRelatedField(
@@ -1725,26 +1764,28 @@ class BomItemSerializer(
help_text=_('Select the component part'), help_text=_('Select the component part'),
) )
sub_part_detail = enable_filter( sub_part_detail = OptionalField(
PartBriefSerializer( serializer_class=PartBriefSerializer,
source='sub_part', serializer_kwargs={
label=_('Component'), 'source': 'sub_part',
many=False, 'label': _('Component'),
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
True, },
default_include=True,
) )
category_detail = enable_filter( category_detail = OptionalField(
CategorySerializer( serializer_class=CategorySerializer,
source='sub_part.category', serializer_kwargs={
label=_('Category'), 'source': 'sub_part.category',
many=False, 'label': _('Category'),
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
False, },
default_include=False,
) )
on_order = serializers.FloatField( on_order = serializers.FloatField(
@@ -1755,41 +1796,61 @@ class BomItemSerializer(
label=_('In Production'), read_only=True, allow_null=True label=_('In Production'), read_only=True, allow_null=True
) )
can_build = enable_filter( can_build = OptionalField(
FilterableFloatField(label=_('Can Build'), read_only=True, allow_null=True), serializer_class=serializers.FloatField,
True, serializer_kwargs={
'label': _('Can Build'),
'read_only': True,
'allow_null': True,
},
default_include=True,
) )
# Cached pricing fields # Cached pricing fields
pricing_min = enable_filter( pricing_min = OptionalField(
InvenTree.serializers.InvenTreeMoneySerializer( serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
source='sub_part.pricing_data.overall_min', allow_null=True, read_only=True serializer_kwargs={
), 'source': 'sub_part.pricing_data.overall_min',
True, 'allow_null': True,
'read_only': True,
},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
pricing_max = enable_filter(
InvenTree.serializers.InvenTreeMoneySerializer( pricing_max = OptionalField(
source='sub_part.pricing_data.overall_max', allow_null=True, read_only=True serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
), serializer_kwargs={
True, 'source': 'sub_part.pricing_data.overall_max',
'allow_null': True,
'read_only': True,
},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
pricing_min_total = enable_filter(
InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True), pricing_min_total = OptionalField(
True, serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
serializer_kwargs={'allow_null': True, 'read_only': True},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
pricing_max_total = enable_filter(
InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True), pricing_max_total = OptionalField(
True, serializer_class=InvenTree.serializers.InvenTreeMoneySerializer,
serializer_kwargs={'allow_null': True, 'read_only': True},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
pricing_updated = enable_filter(
FilterableDateTimeField( pricing_updated = OptionalField(
source='sub_part.pricing_data.updated', allow_null=True, read_only=True serializer_class=serializers.DateTimeField,
), serializer_kwargs={
True, 'source': 'sub_part.pricing_data.updated',
'allow_null': True,
'read_only': True,
},
default_include=True,
filter_name='pricing', filter_name='pricing',
) )
@@ -1887,19 +1948,22 @@ class CategoryParameterTemplateSerializer(
'default_value', 'default_value',
] ]
template_detail = enable_filter( template_detail = OptionalField(
common.serializers.ParameterTemplateSerializer( serializer_class=common.serializers.ParameterTemplateSerializer,
source='template', many=False, read_only=True serializer_kwargs={'source': 'template', 'many': False, 'read_only': True},
), default_include=True,
True,
prefetch_fields=['template'], prefetch_fields=['template'],
) )
category_detail = enable_filter( category_detail = OptionalField(
CategorySerializer( serializer_class=CategorySerializer,
source='category', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'category',
True, 'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
prefetch_fields=['category'], prefetch_fields=['category'],
) )

View File

@@ -1406,6 +1406,53 @@ class PartAPITest(PartAPITestBase):
assert_subset=True, 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): class PartCreationTests(PartAPITestBase):
"""Tests for creating new Part instances via the API.""" """Tests for creating new Part instances via the API."""
@@ -2707,8 +2754,28 @@ class BomItemTest(InvenTreeAPITestCase):
def test_get_bom_detail(self): def test_get_bom_detail(self):
"""Get the detail view for a single BomItem object.""" """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) response = self.get(url, {'substitutes': True}, expected_code=200)
expected_values = [ expected_values = [
@@ -2735,6 +2802,15 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(int(float(response.data['quantity'])), 25) 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 # Increase the quantity
data = response.data data = response.data
data['quantity'] = 57 data['quantity'] = 57

View File

@@ -33,10 +33,10 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import ( from InvenTree.serializers import (
FilterableListField,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
enable_filter, OptionalField,
TreePathSerializer,
) )
from users.serializers import UserSerializer from users.serializers import UserSerializer
@@ -231,8 +231,9 @@ class StockItemTestResultSerializer(
self.fields['user'].read_only = True self.fields['user'].read_only = True
self.fields['date'].read_only = True self.fields['date'].read_only = True
user_detail = enable_filter( user_detail = OptionalField(
UserSerializer(source='user', read_only=True, allow_null=True), serializer_class=UserSerializer,
serializer_kwargs={'source': 'user', 'read_only': True, 'allow_null': True},
prefetch_fields=['user'], prefetch_fields=['user'],
) )
@@ -245,10 +246,9 @@ class StockItemTestResultSerializer(
label=_('Test template for this result'), label=_('Test template for this result'),
) )
template_detail = enable_filter( template_detail = OptionalField(
part_serializers.PartTestTemplateSerializer( serializer_class=part_serializers.PartTestTemplateSerializer,
source='template', read_only=True, allow_null=True serializer_kwargs={'source': 'template', 'read_only': True, 'allow_null': True},
),
prefetch_fields=['template'], prefetch_fields=['template'],
) )
@@ -377,20 +377,20 @@ class StockItemSerializer(
'purchase_price_currency', 'purchase_price_currency',
'use_pack_size', 'use_pack_size',
'serial_numbers', 'serial_numbers',
'tests',
# Annotated fields # Annotated fields
'allocated', 'allocated',
'expired', 'expired',
'installed_items', 'installed_items',
'child_items', 'child_items',
'location_path',
'stale', 'stale',
'tracking_items', # Optional fields (FK relationships)
'tags',
# Detail fields (FK relationships)
'supplier_part_detail',
'part_detail',
'location_detail', 'location_detail',
'location_path',
'part_detail',
'supplier_part_detail',
'tags',
'tests',
'tracking_items',
] ]
read_only_fields = [ read_only_fields = [
'allocated', 'allocated',
@@ -428,13 +428,16 @@ class StockItemSerializer(
help_text=_('Parent stock item'), help_text=_('Parent stock item'),
) )
location_path = enable_filter( location_path = OptionalField(
FilterableListField( serializer_class=TreePathSerializer,
child=serializers.DictField(), serializer_kwargs={
source='location.get_path', 'source': 'location.get_path',
read_only=True, 'extra_fields': ['icon'],
allow_null=True, 'many': True,
), 'read_only': True,
'allow_null': True,
},
default_include=False,
filter_name='path_detail', filter_name='path_detail',
) )
@@ -577,19 +580,20 @@ class StockItemSerializer(
) )
# Optional detail fields, which can be appended via query parameters # Optional detail fields, which can be appended via query parameters
supplier_part_detail = enable_filter( supplier_part_detail = OptionalField(
company_serializers.SupplierPartSerializer( serializer_class=company_serializers.SupplierPartSerializer,
label=_('Supplier Part'), serializer_kwargs={
source='supplier_part', 'label': _('Supplier Part'),
brief=True, 'source': 'supplier_part',
supplier_detail=False, 'brief': True,
manufacturer_detail=False, 'supplier_detail': False,
part_detail=False, 'manufacturer_detail': False,
many=False, 'part_detail': False,
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
False, },
default_include=False,
prefetch_fields=[ prefetch_fields=[
'supplier_part__supplier', 'supplier_part__supplier',
'supplier_part__purchase_order_line_items', 'supplier_part__purchase_order_line_items',
@@ -597,30 +601,40 @@ class StockItemSerializer(
], ],
) )
part_detail = enable_filter( part_detail = OptionalField(
part_serializers.PartBriefSerializer( serializer_class=part_serializers.PartBriefSerializer,
label=_('Part'), source='part', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'label': _('Part'),
True, 'source': 'part',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=True,
) )
location_detail = enable_filter( location_detail = OptionalField(
LocationBriefSerializer( serializer_class=LocationBriefSerializer,
label=_('Location'), serializer_kwargs={
source='location', 'label': _('Location'),
many=False, 'source': 'location',
read_only=True, 'many': False,
allow_null=True, 'read_only': True,
), 'allow_null': True,
False, },
default_include=False,
prefetch_fields=['location'], prefetch_fields=['location'],
) )
tests = enable_filter( tests = OptionalField(
StockItemTestResultSerializer( serializer_class=StockItemTestResultSerializer,
source='test_results', many=True, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'test_results',
False, 'many': True,
'read_only': True,
'allow_null': True,
},
default_include=False,
prefetch_fields=[ prefetch_fields=[
'test_results', 'test_results',
'test_results__user', 'test_results__user',
@@ -1222,13 +1236,16 @@ class LocationSerializer(
tags = common.filters.enable_tags_filter() tags = common.filters.enable_tags_filter()
path = enable_filter( path = OptionalField(
FilterableListField( serializer_class=TreePathSerializer,
child=serializers.DictField(), serializer_kwargs={
source='get_path', 'many': True,
read_only=True, 'source': 'get_path',
allow_null=True, 'extra_fields': ['icon'],
), 'read_only': True,
'allow_null': True,
},
default_include=False,
filter_name='path_detail', filter_name='path_detail',
) )
@@ -1273,21 +1290,37 @@ class StockTrackingSerializer(
label = serializers.CharField(read_only=True) label = serializers.CharField(read_only=True)
item_detail = enable_filter( item_detail = OptionalField(
StockItemSerializer(source='item', many=False, read_only=True, allow_null=True), serializer_class=StockItemSerializer,
serializer_kwargs={
'source': 'item',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['item', 'item__part'], prefetch_fields=['item', 'item__part'],
) )
part_detail = enable_filter( part_detail = OptionalField(
part_serializers.PartBriefSerializer( serializer_class=part_serializers.PartBriefSerializer,
source='part', many=False, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'part',
'many': False,
'read_only': True,
'allow_null': True,
},
default_include=False, default_include=False,
prefetch_fields=['part'], prefetch_fields=['part'],
) )
user_detail = enable_filter( user_detail = OptionalField(
UserSerializer(source='user', many=False, read_only=True, allow_null=True), serializer_class=UserSerializer,
serializer_kwargs={
'source': 'user',
'many': False,
'read_only': True,
'allow_null': True,
},
prefetch_fields=['user'], prefetch_fields=['user'],
) )

View File

@@ -256,7 +256,7 @@ class UserList(ListCreateAPI):
- Otherwise authenticated users have read-only access - Otherwise authenticated users have read-only access
""" """
queryset = User.objects.all() queryset = User.objects.all().prefetch_related('groups')
serializer_class = UserCreateSerializer serializer_class = UserCreateSerializer
# User must have the right role, AND be a staff user, else read-only # User must have the right role, AND be a staff user, else read-only

View File

@@ -11,11 +11,9 @@ from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from InvenTree.serializers import ( from InvenTree.serializers import (
FilterableListSerializer,
FilterableSerializerMethodField,
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeModelSerializer, InvenTreeModelSerializer,
enable_filter, OptionalField,
) )
from .models import ApiToken, Owner, RuleSet, UserProfile from .models import ApiToken, Owner, RuleSet, UserProfile
@@ -56,7 +54,6 @@ class RuleSetSerializer(InvenTreeModelSerializer):
'can_delete', 'can_delete',
] ]
read_only_fields = ['pk', 'name', 'label', 'group'] read_only_fields = ['pk', 'name', 'label', 'group']
list_serializer_class = FilterableListSerializer
class RoleSerializer(InvenTreeModelSerializer): class RoleSerializer(InvenTreeModelSerializer):
@@ -185,7 +182,6 @@ class UserSerializer(InvenTreeModelSerializer):
model = User model = User
fields = ['pk', 'username', 'first_name', 'last_name', 'email'] fields = ['pk', 'username', 'first_name', 'last_name', 'email']
read_only_fields = ['username', 'email'] read_only_fields = ['username', 'email']
list_serializer_class = FilterableListSerializer
username = serializers.CharField(label=_('Username'), help_text=_('Username')) username = serializers.CharField(label=_('Username'), help_text=_('Username'))
@@ -267,8 +263,9 @@ class GroupSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
model = Group model = Group
fields = ['pk', 'name', 'permissions', 'roles', 'users'] fields = ['pk', 'name', 'permissions', 'roles', 'users']
permissions = enable_filter( permissions = OptionalField(
FilterableSerializerMethodField(allow_null=True, read_only=True), serializer_class=serializers.SerializerMethodField,
serializer_kwargs={'allow_null': True, 'read_only': True},
filter_name='permission_detail', filter_name='permission_detail',
) )
@@ -276,16 +273,26 @@ class GroupSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
"""Return a list of permissions associated with the group.""" """Return a list of permissions associated with the group."""
return generate_permission_dict(group.permissions.all()) return generate_permission_dict(group.permissions.all())
roles = enable_filter( roles = OptionalField(
RuleSetSerializer( serializer_class=RuleSetSerializer,
source='rule_sets', many=True, read_only=True, allow_null=True serializer_kwargs={
), 'source': 'rule_sets',
'many': True,
'read_only': True,
'allow_null': True,
},
filter_name='role_detail', filter_name='role_detail',
prefetch_fields=['rule_sets'], prefetch_fields=['rule_sets'],
) )
users = enable_filter( users = OptionalField(
UserSerializer(source='user_set', many=True, read_only=True, allow_null=True), serializer_class=UserSerializer,
serializer_kwargs={
'source': 'user_set',
'many': True,
'read_only': True,
'allow_null': True,
},
filter_name='user_detail', filter_name='user_detail',
prefetch_fields=['user_set'], prefetch_fields=['user_set'],
) )

View File

@@ -218,8 +218,11 @@ class UserAPITests(InvenTreeAPITestCase):
expected_code=200, expected_code=200,
) )
self.assertIn('name', response.data) self.assertIn('name', response.data)
self.assertIn('roles', response.data)
self.assertIn('permissions', response.data) self.assertIn('permissions', response.data)
self.assertGreater(len(response.data['roles']), 0)
def test_login_redirect(self): def test_login_redirect(self):
"""Test login redirect endpoint.""" """Test login redirect endpoint."""
response = self.get(reverse('api-login-redirect'), expected_code=302) response = self.get(reverse('api-login-redirect'), expected_code=302)