2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-17 01:38:19 +00:00

[refactor] Optional prefetch (#11012)

* Automatic prefetch of related fields for enable_filter

- Allows us to *not* prefetch fields (expensive) when they are not going to be used
- Enables re-usable components for common detail fields

* Refactor "project_code_detail" filter into common component

- Automatically apply correct prefetch fields

* Refactor 'parameters' annotation

- add 'enable_parameters_filter' function
- Prefetch parameters only when needed
- Refactor / consolidate code

* Refactor SupplierPartSerializer

- Make fields switchable
- Ensure correct prefetch_related

* Refactor serializer for ManufacturerPart

* Refactor BuildSerializer

* Refactor PurchaseOrderSerializer

* Refactor SalesOrderSerializer

* Refactor ReturnOrderSerializer

* Remove debug statements

* Tweaks

* Simplify custom filterable fields

* Bump API version

* Fix for data export

* Additional unit tests

* Remove unused "prefetch_func" option

* Refactor PurchaseOrderLineItemList

* Refactor SalesOrderLineItemList

* Refactor ReturnOrderLineItem

* Cleanup "pretty_name"

* Fix for build list

* Refactoring StockItem API endpoint

- Needs significant work still

* Refactoring for BuildLineSerializer

* Keep all optional fields when exporting data

* Improve "UserRoles" API endpoint

- Prefetch roles
- Prevents significant number of db hits

* Prefetch Parameter API list

* Bug fix for exporting logic

* Specify InvenTreeOutputOption

* Optional prefetch for primary_address

* Fix typing

* Fix unit test

* fixes for playwright tests

* Update Part API

- Improved prefetching

* Fix for prefetch
This commit is contained in:
Oliver
2025-12-16 14:46:17 +11:00
committed by GitHub
parent 1e120c3589
commit ba7b776257
25 changed files with 508 additions and 420 deletions

View File

@@ -1,11 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 431 INVENTREE_API_VERSION = 432
"""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 = """
v432 -> 2025-12-15 : https://github.com/inventree/InvenTree/pull/11012
- The "part_detail" field on the SupplierPart API endpoint is now optional
- The "supplier_detail" field on the SupplierPart API endpoint is now optional
- The "manufacturer_detail" field on the ManufacturerPart API endpoint is now optional
- The "part_detail" field on the StockItem API is now disabled by default
v431 -> 2025-12-14 : https://github.com/inventree/InvenTree/pull/11006 v431 -> 2025-12-14 : https://github.com/inventree/InvenTree/pull/11006
- Remove duplicate "address" field on the Company API endpoint - Remove duplicate "address" field on the Company API endpoint
- Make "primary_address" field optional on the Company API endpoint - Make "primary_address" field optional on the Company API endpoint

View File

@@ -258,6 +258,19 @@ class OutputOptionsMixin:
return serializer return serializer
def get_queryset(self):
"""Return the queryset with output options applied.
This automatically applies any prefetching defined against the optional fields.
"""
queryset = super().get_queryset()
serializer = self.get_serializer()
if isinstance(serializer, FilterableSerializerMixin):
queryset = serializer.prefetch_queryset(queryset)
return queryset
class SerializerContextMixin: class SerializerContextMixin:
"""Mixin to add context to serializer.""" """Mixin to add context to serializer."""

View File

@@ -10,6 +10,7 @@ from django.conf import settings
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
from django.db import models from django.db import models
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from djmoney.contrib.django_rest_framework.fields import MoneyField from djmoney.contrib.django_rest_framework.fields import MoneyField
@@ -44,11 +45,15 @@ class FilterableSerializerField:
is_filterable = None is_filterable = None
is_filterable_vals = {} is_filterable_vals = {}
# Options for automatic queryset prefetching
prefetch_fields: Optional[list[str]] = None
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize the serializer.""" """Initialize the serializer."""
if self.is_filterable is None: # Materialize parameters for later usage
self.is_filterable = kwargs.pop('is_filterable', None) self.is_filterable = kwargs.pop('is_filterable', None)
self.is_filterable_vals = kwargs.pop('is_filterable_vals', {}) self.is_filterable_vals = kwargs.pop('is_filterable_vals', {})
self.prefetch_fields = kwargs.pop('prefetch_fields', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -57,6 +62,7 @@ def enable_filter(
default_include: bool = False, default_include: bool = False,
filter_name: Optional[str] = None, filter_name: Optional[str] = None,
filter_by_query: bool = True, filter_by_query: bool = True,
prefetch_fields: Optional[list[str]] = None,
): ):
"""Decorator for marking a serializer field as filterable. """Decorator for marking a serializer field as filterable.
@@ -67,6 +73,7 @@ def enable_filter(
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. 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_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. 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: Returns:
The decorated serializer field, marked as filterable. The decorated serializer field, marked as filterable.
@@ -84,6 +91,10 @@ def enable_filter(
'filter_name': filter_name if filter_name else func.field_name, 'filter_name': filter_name if filter_name else func.field_name,
'filter_by_query': filter_by_query, 'filter_by_query': filter_by_query,
} }
# Attach queryset prefetching information
func._kwargs['prefetch_fields'] = prefetch_fields
return func return func
@@ -113,6 +124,34 @@ class FilterableSerializerMixin:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.do_filtering() self.do_filtering()
def prefetch_queryset(self, queryset: QuerySet) -> QuerySet:
"""Apply any prefetching to the queryset based on the optionally included fields.
Args:
queryset: The original queryset.
Returns:
The modified queryset with prefetching applied.
"""
# Gather up the set of simple 'prefetch' fields and functions
prefetch_fields = set()
filterable_fields = [
field
for field in self.fields.values()
if getattr(field, 'is_filterable', None)
]
for field in filterable_fields:
if prefetch_names := getattr(field, 'prefetch_fields', None):
for pf in prefetch_names:
prefetch_fields.add(pf)
if prefetch_fields and len(prefetch_fields) > 0:
queryset = queryset.prefetch_related(*list(prefetch_fields))
return queryset
def gather_filters(self, kwargs) -> None: def gather_filters(self, kwargs) -> None:
"""Gather filterable fields through introspection.""" """Gather filterable fields through introspection."""
# Fast exit if this has already been done or would not have any effect # Fast exit if this has already been done or would not have any effect
@@ -168,6 +207,10 @@ class FilterableSerializerMixin:
): ):
return return
# Skip filtering when exporting data - leave all fields intact
if getattr(self, '_exporting_data', False):
return
# Throw out fields which are not requested (either by default or explicitly) # Throw out fields which are not requested (either by default or explicitly)
for k, v in self.filter_target_values.items(): for k, v in self.filter_target_values.items():
# See `enable_filter` where` is_filterable and is_filterable_vals are set # See `enable_filter` where` is_filterable and is_filterable_vals are set

View File

@@ -319,15 +319,6 @@ class BuildMixin:
queryset = build.serializers.BuildSerializer.annotate_queryset(queryset) queryset = build.serializers.BuildSerializer.annotate_queryset(queryset)
queryset = queryset.prefetch_related(
'responsible',
'issued_by',
'build_lines',
'part',
'part__pricing_data',
'project_code',
)
return queryset return queryset
@@ -568,26 +559,27 @@ class BuildLineOutputOptions(OutputConfiguration):
InvenTreeOutputOption( InvenTreeOutputOption(
'bom_item_detail', 'bom_item_detail',
description='Include detailed information about the BOM item linked to this build line.', description='Include detailed information about the BOM item linked to this build line.',
default=True, default=False,
), ),
InvenTreeOutputOption( InvenTreeOutputOption(
'assembly_detail', 'assembly_detail',
description='Include brief details of the assembly (parent part) related to the BOM item in this build line.', description='Include brief details of the assembly (parent part) related to the BOM item in this build line.',
default=True, default=False,
), ),
InvenTreeOutputOption( InvenTreeOutputOption(
'part_detail', 'part_detail',
description='Include detailed information about the specific part being built or consumed in this build line.', description='Include detailed information about the specific part being built or consumed in this build line.',
default=True, default=False,
), ),
InvenTreeOutputOption( InvenTreeOutputOption(
'build_detail', 'build_detail',
description='Include detailed information about the associated build order.', description='Include detailed information about the associated build order.',
default=False,
), ),
InvenTreeOutputOption( InvenTreeOutputOption(
'allocations', 'allocations',
description='Include allocation details showing which stock items are allocated to this build line.', description='Include allocation details showing which stock items are allocated to this build line.',
default=True, default=False,
), ),
] ]
@@ -905,6 +897,7 @@ class BuildItemOutputOptions(OutputConfiguration):
InvenTreeOutputOption('location_detail'), InvenTreeOutputOption('location_detail'),
InvenTreeOutputOption('stock_detail'), InvenTreeOutputOption('stock_detail'),
InvenTreeOutputOption('build_detail'), InvenTreeOutputOption('build_detail'),
InvenTreeOutputOption('supplier_part_detail'),
] ]
@@ -927,26 +920,9 @@ class BuildItemList(
"""Override the queryset method, to perform custom prefetch.""" """Override the queryset method, to perform custom prefetch."""
queryset = super().get_queryset() queryset = super().get_queryset()
queryset = queryset.select_related( queryset = queryset.select_related('install_into').prefetch_related(
'build_line', 'build_line', 'build_line__build', 'build_line__bom_item'
'build_line__build', )
'build_line__build__part',
'build_line__build__responsible',
'build_line__build__issued_by',
'build_line__build__project_code',
'build_line__build__part__pricing_data',
'build_line__bom_item',
'build_line__bom_item__part',
'build_line__bom_item__sub_part',
'install_into',
'stock_item',
'stock_item__location',
'stock_item__part',
'stock_item__supplier_part__part',
'stock_item__supplier_part__supplier',
'stock_item__supplier_part__manufacturer_part',
'stock_item__supplier_part__manufacturer_part__manufacturer',
).prefetch_related('stock_item__location__tags', 'stock_item__tags')
return queryset return queryset

View File

@@ -22,18 +22,16 @@ from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
import build.tasks import build.tasks
import common.serializers import common.filters
import common.settings import common.settings
import company.serializers import company.serializers
import InvenTree.helpers import InvenTree.helpers
import part.filters import part.filters
import part.serializers as part_serializers import part.serializers as part_serializers
from common.serializers import ProjectCodeSerializer
from common.settings import get_global_setting from common.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import ( from InvenTree.serializers import (
FilterableCharField,
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
@@ -124,13 +122,10 @@ class BuildSerializer(
part_detail = enable_filter( part_detail = enable_filter(
part_serializers.PartBriefSerializer(source='part', many=False, read_only=True), part_serializers.PartBriefSerializer(source='part', many=False, read_only=True),
True, True,
prefetch_fields=['part', 'part__category', 'part__pricing_data'],
) )
parameters = enable_filter( parameters = common.filters.enable_parameters_filter()
common.serializers.ParameterSerializer(many=True, read_only=True),
False,
filter_name='parameters',
)
part_name = serializers.CharField( part_name = serializers.CharField(
source='part.name', read_only=True, label=_('Part Name') source='part.name', read_only=True, label=_('Part Name')
@@ -144,34 +139,21 @@ class BuildSerializer(
UserSerializer(source='issued_by', read_only=True), UserSerializer(source='issued_by', read_only=True),
True, True,
filter_name='user_detail', filter_name='user_detail',
prefetch_fields=['issued_by'],
) )
responsible_detail = enable_filter( responsible_detail = enable_filter(
OwnerSerializer(source='responsible', read_only=True, allow_null=True), OwnerSerializer(source='responsible', read_only=True, allow_null=True),
True, True,
filter_name='user_detail', filter_name='user_detail',
prefetch_fields=['responsible'],
) )
barcode_hash = serializers.CharField(read_only=True) barcode_hash = serializers.CharField(read_only=True)
project_code_label = enable_filter( project_code_label = common.filters.enable_project_label_filter()
FilterableCharField(
source='project_code.code',
read_only=True,
label=_('Project Code Label'),
allow_null=True,
),
True,
filter_name='project_code_detail',
)
project_code_detail = enable_filter( project_code_detail = common.filters.enable_project_code_filter()
ProjectCodeSerializer(
source='project_code', many=False, read_only=True, allow_null=True
),
True,
filter_name='project_code_detail',
)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
@@ -192,8 +174,6 @@ class BuildSerializer(
) )
) )
queryset = Build.annotate_parameters(queryset)
return queryset return queryset
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1240,6 +1220,7 @@ class BuildItemSerializer(
pricing=False, pricing=False,
), ),
True, True,
prefetch_fields=['stock_item__part'],
) )
stock_item_detail = enable_filter( stock_item_detail = enable_filter(
@@ -1255,6 +1236,13 @@ class BuildItemSerializer(
), ),
True, True,
filter_name='stock_detail', filter_name='stock_detail',
prefetch_fields=[
'stock_item',
'stock_item__tags',
'stock_item__part',
'stock_item__supplier_part',
'stock_item__supplier_part__manufacturer_part',
],
) )
location = serializers.PrimaryKeyRelatedField( location = serializers.PrimaryKeyRelatedField(
@@ -1269,6 +1257,7 @@ class BuildItemSerializer(
allow_null=True, allow_null=True,
), ),
True, True,
prefetch_fields=['stock_item__location', 'stock_item__location__tags'],
) )
build_detail = enable_filter( build_detail = enable_filter(
@@ -1280,15 +1269,32 @@ class BuildItemSerializer(
allow_null=True, allow_null=True,
), ),
True, True,
prefetch_fields=[
'build_line__build',
'build_line__build__part',
'build_line__build__responsible',
'build_line__build__issued_by',
'build_line__build__project_code',
'build_line__build__part__pricing_data',
],
) )
supplier_part_detail = company.serializers.SupplierPartSerializer( supplier_part_detail = enable_filter(
company.serializers.SupplierPartSerializer(
label=_('Supplier Part'), label=_('Supplier Part'),
source='stock_item.supplier_part', source='stock_item.supplier_part',
many=False, many=False,
read_only=True, read_only=True,
allow_null=True, allow_null=True,
brief=True, brief=True,
),
False,
prefetch_fields=[
'stock_item__supplier_part',
'stock_item__supplier_part__supplier',
'stock_item__supplier_part__manufacturer_part',
'stock_item__supplier_part__manufacturer_part__manufacturer',
],
) )
quantity = InvenTreeDecimalField(label=_('Allocated Quantity')) quantity = InvenTreeDecimalField(label=_('Allocated Quantity'))
@@ -1373,7 +1379,18 @@ class BuildLineSerializer(
) )
allocations = enable_filter( allocations = enable_filter(
BuildItemSerializer(many=True, read_only=True, build_detail=False), True BuildItemSerializer(many=True, read_only=True, build_detail=False),
True,
prefetch_fields=[
'allocations',
'allocations__stock_item',
'allocations__stock_item__part',
'allocations__stock_item__part__pricing_data',
'allocations__stock_item__supplier_part',
'allocations__stock_item__supplier_part__manufacturer_part',
'allocations__stock_item__location',
'allocations__stock_item__tags',
],
) )
# BOM item info fields # BOM item info fields
@@ -1417,7 +1434,7 @@ class BuildLineSerializer(
part_detail=False, part_detail=False,
can_build=False, can_build=False,
), ),
True, False,
) )
assembly_detail = enable_filter( assembly_detail = enable_filter(
@@ -1429,7 +1446,8 @@ class BuildLineSerializer(
allow_null=True, allow_null=True,
pricing=False, pricing=False,
), ),
True, False,
prefetch_fields=['bom_item__part', 'bom_item__part__pricing_data'],
) )
part_detail = enable_filter( part_detail = enable_filter(
@@ -1440,7 +1458,8 @@ class BuildLineSerializer(
read_only=True, read_only=True,
pricing=False, pricing=False,
), ),
True, False,
prefetch_fields=['bom_item__sub_part', 'bom_item__sub_part__pricing_data'],
) )
category_detail = enable_filter( category_detail = enable_filter(
@@ -1452,6 +1471,7 @@ class BuildLineSerializer(
allow_null=True, allow_null=True,
), ),
False, False,
prefetch_fields=['bom_item__sub_part__category'],
) )
build_detail = enable_filter( build_detail = enable_filter(
@@ -1517,77 +1537,16 @@ class BuildLineSerializer(
'bom_item__sub_part__pricing_data', 'bom_item__sub_part__pricing_data',
) )
# Pre-fetch related fields
queryset = queryset.prefetch_related(
'allocations',
'allocations__stock_item',
'allocations__stock_item__part',
'allocations__stock_item__supplier_part',
'allocations__stock_item__supplier_part__manufacturer_part',
'allocations__stock_item__location',
'allocations__stock_item__tags',
'bom_item',
'bom_item__part',
'bom_item__sub_part',
'bom_item__sub_part__category',
'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations',
'bom_item__substitutes',
'bom_item__substitutes__part__stock_items',
'bom_item__substitutes__part__stock_items__allocations',
'bom_item__substitutes__part__stock_items__sales_order_allocations',
)
# Defer expensive fields which we do not need for this serializer # Defer expensive fields which we do not need for this serializer
queryset = ( queryset = queryset.defer(
queryset.defer(
'build__lft',
'build__rght',
'build__level',
'build__tree_id',
'build__destination',
'build__take_from',
'build__completed_by',
'build__sales_order',
'build__parent',
'build__notes', 'build__notes',
'build__metadata', 'build__metadata',
'build__responsible', 'bom_item__metadata',
'build__barcode_data',
'build__barcode_hash',
'build__project_code',
)
.defer('bom_item__metadata')
.defer(
'bom_item__part__lft',
'bom_item__part__rght',
'bom_item__part__level',
'bom_item__part__tree_id',
'bom_item__part__tags',
'bom_item__part__notes', 'bom_item__part__notes',
'bom_item__part__variant_of', 'bom_item__part__metadata',
'bom_item__part__revision_of',
'bom_item__part__creation_user',
'bom_item__part__bom_checked_by',
'bom_item__part__default_supplier',
'bom_item__part__responsible_owner',
)
.defer(
'bom_item__sub_part__lft',
'bom_item__sub_part__rght',
'bom_item__sub_part__level',
'bom_item__sub_part__tree_id',
'bom_item__sub_part__tags',
'bom_item__sub_part__notes', 'bom_item__sub_part__notes',
'bom_item__sub_part__variant_of', 'bom_item__sub_part__metadata',
'bom_item__sub_part__revision_of',
'bom_item__sub_part__creation_user',
'bom_item__sub_part__bom_checked_by',
'bom_item__sub_part__default_supplier',
'bom_item__sub_part__responsible_owner',
)
) )
# Annotate the "allocated" quantity # Annotate the "allocated" quantity

View File

@@ -848,7 +848,9 @@ class ParameterTemplateFilter(FilterSet):
class ParameterTemplateMixin: class ParameterTemplateMixin:
"""Mixin class for ParameterTemplate views.""" """Mixin class for ParameterTemplate views."""
queryset = common.models.ParameterTemplate.objects.all() queryset = common.models.ParameterTemplate.objects.all().prefetch_related(
'model_type'
)
serializer_class = common.serializers.ParameterTemplateSerializer serializer_class = common.serializers.ParameterTemplateSerializer
permission_classes = [IsAuthenticatedOrReadScope] permission_classes = [IsAuthenticatedOrReadScope]
@@ -891,7 +893,9 @@ class ParameterFilter(FilterSet):
class ParameterMixin: class ParameterMixin:
"""Mixin class for Parameter views.""" """Mixin class for Parameter views."""
queryset = common.models.Parameter.objects.all() queryset = common.models.Parameter.objects.all().prefetch_related(
'model_type', 'updated_by', 'template', 'template__model_type'
)
serializer_class = common.serializers.ParameterSerializer serializer_class = common.serializers.ParameterSerializer
permission_classes = [IsAuthenticatedOrReadScope] permission_classes = [IsAuthenticatedOrReadScope]

View File

@@ -17,9 +17,11 @@ from django.db.models import (
When, When,
) )
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.utils.translation import gettext_lazy as _
import InvenTree.conversion import InvenTree.conversion
import InvenTree.helpers import InvenTree.helpers
import InvenTree.serializers
def determine_content_type(content_type: str | int | None) -> ContentType | None: def determine_content_type(content_type: str | int | None) -> ContentType | None:
@@ -310,3 +312,71 @@ def order_by_parameter(
f'{prefix}parameter_value_numeric', f'{prefix}parameter_value_numeric',
f'{prefix}parameter_value', f'{prefix}parameter_value',
) )
def enable_project_code_filter(default: bool = True):
"""Add an optional 'project_code_detail' field to an API serializer.
Arguments:
filter_name: The name of the filter field.
default: If True, enable the filter by default.
If applied, this field will automatically prefetch the 'project_code' relationship.
"""
from common.serializers import ProjectCodeSerializer
return InvenTree.serializers.enable_filter(
ProjectCodeSerializer(
source='project_code', many=False, read_only=True, allow_null=True
),
default,
filter_name='project_code_detail',
prefetch_fields=['project_code'],
)
def enable_project_label_filter(default: bool = True):
"""Add an optional 'project_code_label' field to an API serializer.
Arguments:
filter_name: The name of the filter field.
default: If True, enable the filter by default.
If applied, this field will automatically prefetch the 'project_code' relationship.
"""
return InvenTree.serializers.enable_filter(
InvenTree.serializers.FilterableCharField(
source='project_code.code',
read_only=True,
label=_('Project Code Label'),
allow_null=True,
),
default,
filter_name='project_code_detail',
prefetch_fields=['project_code'],
)
def enable_parameters_filter():
"""Add an optional 'parameters' field to an API serializer.
Arguments:
source: The source field for the serializer.
filter_name: The name of the filter field.
default: If True, enable the filter by default.
If applied, this field will automatically annotate the queryset with parameter data.
"""
from common.serializers import ParameterSerializer
return InvenTree.serializers.enable_filter(
ParameterSerializer(many=True, read_only=True, allow_null=True),
False,
filter_name='parameters',
prefetch_fields=[
'parameters_list',
'parameters_list__model_type',
'parameters_list__updated_by',
'parameters_list__template',
],
)

View File

@@ -178,11 +178,7 @@ class ManufacturerPartMixin(SerializerContextMixin):
"""Return annotated queryset for the ManufacturerPart list endpoint.""" """Return annotated queryset for the ManufacturerPart list endpoint."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related('supplier_parts', 'tags')
'part', 'manufacturer', 'supplier_parts', 'tags'
)
queryset = ManufacturerPart.annotate_parameters(queryset)
return queryset return queryset
@@ -304,13 +300,18 @@ class SupplierPartOutputOptions(OutputConfiguration):
InvenTreeOutputOption( InvenTreeOutputOption(
description='Include detailed information about the Supplier in the response', description='Include detailed information about the Supplier in the response',
flag='supplier_detail', flag='supplier_detail',
default=True, default=False,
), ),
InvenTreeOutputOption( InvenTreeOutputOption(
description='Include detailed information about the Manufacturer in the response', description='Include detailed information about the Manufacturer in the response',
flag='manufacturer_detail', flag='manufacturer_detail',
default=False, default=False,
), ),
InvenTreeOutputOption(
flag='manufacturer_part_detail',
description='Include detailed information about the linked ManufacturerPart in the response',
default=False,
),
InvenTreeOutputOption( InvenTreeOutputOption(
description='Format the output with a more readable (pretty) name', description='Format the output with a more readable (pretty) name',
flag='pretty', flag='pretty',

View File

@@ -10,7 +10,7 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from taggit.serializers import TagListSerializerField from taggit.serializers import TagListSerializerField
import common.serializers import common.filters
import company.filters import company.filters
import part.filters import part.filters
import part.serializers as part_serializers import part.serializers as part_serializers
@@ -163,22 +163,19 @@ class CompanySerializer(
queryset = queryset.annotate(parts_supplied=SubqueryCount('supplied_parts')) queryset = queryset.annotate(parts_supplied=SubqueryCount('supplied_parts'))
queryset = queryset.prefetch_related( return queryset
primary_address = enable_filter(
AddressBriefSerializer(read_only=True, allow_null=True),
False,
filter_name='address_detail',
prefetch_fields=[
Prefetch( Prefetch(
'addresses', 'addresses',
queryset=Address.objects.filter(primary=True), queryset=Address.objects.filter(primary=True),
to_attr='primary_address_list', to_attr='primary_address_list',
) )
) ],
queryset = Company.annotate_parameters(queryset)
return queryset
primary_address = enable_filter(
AddressBriefSerializer(read_only=True, allow_null=True),
True,
filter_name='address_detail',
) )
image = InvenTreeImageSerializerField(required=False, allow_null=True) image = InvenTreeImageSerializerField(required=False, allow_null=True)
@@ -195,11 +192,7 @@ class CompanySerializer(
help_text=_('Default currency used for this supplier'), required=True help_text=_('Default currency used for this supplier'), required=True
) )
parameters = enable_filter( parameters = common.filters.enable_parameters_filter()
common.serializers.ParameterSerializer(many=True, read_only=True),
False,
filter_name='parameters',
)
def save(self): def save(self):
"""Save the Company instance.""" """Save the Company instance."""
@@ -269,24 +262,14 @@ class ManufacturerPartSerializer(
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
parameters = enable_filter( parameters = common.filters.enable_parameters_filter()
common.serializers.ParameterSerializer(many=True, read_only=True),
False,
filter_name='parameters',
)
part_detail = enable_filter( part_detail = enable_filter(
part_serializers.PartBriefSerializer( part_serializers.PartBriefSerializer(
source='part', many=False, read_only=True, allow_null=True source='part', many=False, read_only=True, allow_null=True
), ),
True, True,
) prefetch_fields=['part'],
manufacturer_detail = enable_filter(
CompanyBriefSerializer(
source='manufacturer', many=False, read_only=True, allow_null=True
),
True,
) )
pretty_name = enable_filter( pretty_name = enable_filter(
@@ -297,6 +280,14 @@ class ManufacturerPartSerializer(
queryset=Company.objects.filter(is_manufacturer=True) queryset=Company.objects.filter(is_manufacturer=True)
) )
manufacturer_detail = enable_filter(
CompanyBriefSerializer(
source='manufacturer', many=False, read_only=True, allow_null=True
),
True,
prefetch_fields=['manufacturer'],
)
class SupplierPriceBreakBriefSerializer( class SupplierPriceBreakBriefSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer FilterableSerializerMixin, InvenTreeModelSerializer
@@ -399,33 +390,13 @@ class SupplierPartSerializer(
# Check if 'available' quantity was supplied # Check if 'available' quantity was supplied
self.has_available_quantity = 'available' in kwargs.get('data', {}) self.has_available_quantity = 'available' in kwargs.get('data', {})
# TODO INVE-T1 support complex filters
brief = kwargs.pop('brief', False) brief = kwargs.pop('brief', False)
detail_default = not brief
part_detail = kwargs.pop('part_detail', detail_default)
supplier_detail = kwargs.pop('supplier_detail', detail_default)
manufacturer_detail = kwargs.pop('manufacturer_detail', detail_default)
prettify = kwargs.pop('pretty', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if isGeneratingSchema(): if isGeneratingSchema():
return return
if part_detail is not True:
self.fields.pop('part_detail', None)
if supplier_detail is not True:
self.fields.pop('supplier_detail', None)
if manufacturer_detail is not True:
self.fields.pop('manufacturer_detail', None)
self.fields.pop('manufacturer_part_detail', None)
if brief or prettify is not True:
self.fields.pop('pretty_name', None)
if brief: if brief:
self.fields.pop('tags') self.fields.pop('tags')
self.fields.pop('available') self.fields.pop('available')
@@ -455,46 +426,61 @@ class SupplierPartSerializer(
), ),
False, False,
filter_name='price_breaks', filter_name='price_breaks',
prefetch_fields=['pricebreaks'],
) )
parameters = enable_filter( parameters = common.filters.enable_parameters_filter()
common.serializers.ParameterSerializer(many=True, read_only=True),
False,
filter_name='parameters',
)
part_detail = part_serializers.PartBriefSerializer( part_detail = enable_filter(
part_serializers.PartBriefSerializer(
label=_('Part'), source='part', many=False, read_only=True, allow_null=True label=_('Part'), source='part', many=False, read_only=True, allow_null=True
),
False,
prefetch_fields=['part'],
) )
supplier_detail = CompanyBriefSerializer( supplier_detail = enable_filter(
CompanyBriefSerializer(
label=_('Supplier'), label=_('Supplier'),
source='supplier', source='supplier',
many=False, many=False,
read_only=True, read_only=True,
allow_null=True, allow_null=True,
),
False,
prefetch_fields=['supplier'],
) )
manufacturer_detail = CompanyBriefSerializer( manufacturer_detail = enable_filter(
CompanyBriefSerializer(
label=_('Manufacturer'), label=_('Manufacturer'),
source='manufacturer_part.manufacturer', source='manufacturer_part.manufacturer',
many=False, many=False,
read_only=True, read_only=True,
allow_null=True, allow_null=True,
),
False,
prefetch_fields=['manufacturer_part__manufacturer'],
) )
pretty_name = serializers.CharField(read_only=True, allow_null=True) pretty_name = enable_filter(
FilterableCharField(read_only=True, allow_null=True), 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 = ManufacturerPartSerializer( manufacturer_part_detail = enable_filter(
ManufacturerPartSerializer(
label=_('Manufacturer Part'), label=_('Manufacturer Part'),
source='manufacturer_part', source='manufacturer_part',
part_detail=False, part_detail=False,
read_only=True, read_only=True,
allow_null=True, allow_null=True,
),
False,
prefetch_fields=['manufacturer_part'],
) )
MPN = serializers.CharField( MPN = serializers.CharField(
@@ -511,15 +497,13 @@ class SupplierPartSerializer(
Fields: Fields:
in_stock: Current stock quantity for each SupplierPart in_stock: Current stock quantity for each SupplierPart
""" """
queryset = queryset.prefetch_related('part', 'pricebreaks')
queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock()) queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock())
queryset = queryset.annotate( queryset = queryset.annotate(
on_order=company.filters.annotate_on_order_quantity() on_order=company.filters.annotate_on_order_quantity()
) )
queryset = SupplierPart.annotate_parameters(queryset) queryset = queryset.prefetch_related('supplier', 'manufacturer_part')
return queryset return queryset

View File

@@ -210,6 +210,34 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertEqual(response.data['notes'], note) self.assertEqual(response.data['notes'], note)
def test_company_parameters(self):
"""Test for annotation of 'parameters' field in Company API."""
url = reverse('api-company-list')
response = self.get(url, expected_code=200)
self.assertGreater(len(response.data), 0)
# Default = not included
for result in response.data:
self.assertNotIn('parameters', result)
# Exclude parameters
response = self.get(url, {'parameters': 'false'}, expected_code=200)
self.assertGreater(len(response.data), 0)
for result in response.data:
self.assertNotIn('parameters', result)
# Include parameters
response = self.get(url, {'parameters': 'true'}, expected_code=200)
self.assertGreater(len(response.data), 0)
for result in response.data:
self.assertIn('parameters', result)
class ContactTest(InvenTreeAPITestCase): class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models.""" """Tests for the Contact models."""
@@ -682,6 +710,42 @@ class SupplierPartTest(InvenTreeAPITestCase):
for result in response.data: for result in response.data:
self.assertEqual(result['supplier'], company.pk) self.assertEqual(result['supplier'], company.pk)
def test_filterable_fields(self):
"""Test inclusion/exclusion of optional API fields."""
fields = {
'price_breaks': False,
'part_detail': False,
'supplier_detail': False,
'manufacturer_detail': False,
'manufacturer_part_detail': False,
}
url = reverse('api-supplier-part-list')
for field, included in fields.items():
# Test default behavior
response = self.get(url, data={}, expected_code=200)
self.assertGreater(len(response.data), 0)
self.assertEqual(
included,
field in response.data[0],
f'Field: {field} failed default test',
)
# Test explicit inclusion
response = self.get(url, data={field: 'true'}, expected_code=200)
self.assertGreater(len(response.data), 0)
self.assertIn(
field, response.data[0], f'Field: {field} failed inclusion test'
)
# Test explicit exclusion
response = self.get(url, data={field: 'false'}, expected_code=200)
self.assertGreater(len(response.data), 0)
self.assertNotIn(
field, response.data[0], f'Field: {field} failed exclusion test'
)
class CompanyMetadataAPITest(InvenTreeAPITestCase): class CompanyMetadataAPITest(InvenTreeAPITestCase):
"""Unit tests for the various metadata endpoints of API.""" """Unit tests for the various metadata endpoints of API."""

View File

@@ -53,7 +53,7 @@ class DataExportSerializerMixin:
Determine if the serializer is being used for data export, Determine if the serializer is being used for data export,
and if so, adjust the serializer fields accordingly. and if so, adjust the serializer fields accordingly.
""" """
exporting = kwargs.pop('exporting', False) self._exporting_data = exporting = kwargs.pop('exporting', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -264,10 +264,8 @@ class DataExportViewMixin:
exporting = kwargs.pop('exporting', None) exporting = kwargs.pop('exporting', None)
if exporting is None: if exporting is None:
exporting = ( method = str(getattr(self.request, 'method', '')).lower()
self.request.method.lower() in ['options', 'get'] exporting = method in ['options', 'get'] and self.is_exporting()
and self.is_exporting()
)
if exporting: if exporting:
# Override kwargs when initializing the DataExportOptionsSerializer # Override kwargs when initializing the DataExportOptionsSerializer

View File

@@ -369,12 +369,10 @@ class PurchaseOrderMixin(SerializerContextMixin):
"""Return the annotated queryset for this endpoint.""" """Return the annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'supplier', 'project_code', 'lines', 'responsible'
)
queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset)
queryset = queryset.prefetch_related('supplier', 'created_by')
return queryset return queryset
@@ -833,9 +831,7 @@ class SalesOrderMixin(SerializerContextMixin):
"""Return annotated queryset for this endpoint.""" """Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related('customer', 'created_by')
'customer', 'responsible', 'project_code', 'lines'
)
queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset)
@@ -1018,17 +1014,13 @@ class SalesOrderLineItemMixin(SerializerContextMixin):
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'part', 'part',
'part__stock_items',
'allocations', 'allocations',
'allocations__shipment', 'allocations__shipment',
'allocations__item__part', 'allocations__item__part',
'allocations__item__location', 'allocations__item__location',
'order', 'order',
'order__stock_items',
) )
queryset = queryset.select_related('part__pricing_data')
queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset) queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset)
return queryset return queryset
@@ -1510,9 +1502,7 @@ class ReturnOrderMixin(SerializerContextMixin):
"""Return annotated queryset for this endpoint.""" """Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related('customer', 'created_by')
'customer', 'lines', 'project_code', 'responsible'
)
queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset) queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset)

View File

@@ -1989,17 +1989,16 @@ class PurchaseOrderLineItem(OrderLineItem):
def get_destination(self): def get_destination(self):
"""Show where the line item is or should be placed. """Show where the line item is or should be placed.
NOTE: If a line item gets split when received, only an arbitrary 1. If a destination is specified against this line item, return that.
stock items location will be reported as the location for the 2. If a destination is specified against the PurchaseOrderPart, return that.
entire line. 3. If a default location is specified against the linked Part, return that.
""" """
for item in stock.models.StockItem.objects.filter(
supplier_part=self.part, purchase_order=self.order
):
if item.location:
return item.location
if self.destination: if self.destination:
return self.destination return self.destination
if self.order.destination:
return self.order.destination
if self.part and self.part.part and self.part.part.default_location: if self.part and self.part.part and self.part.part.default_location:
return self.part.part.default_location return self.part.part.default_location

View File

@@ -4,16 +4,7 @@ from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ( from django.db.models import BooleanField, Case, ExpressionWrapper, F, Q, Value, When
BooleanField,
Case,
ExpressionWrapper,
F,
Prefetch,
Q,
Value,
When,
)
from django.db.models.functions import Coalesce, Greatest from django.db.models.functions import Coalesce, Greatest
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -22,13 +13,12 @@ from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
import build.serializers import build.serializers
import common.serializers import common.filters
import order.models import order.models
import part.filters as part_filters import part.filters as part_filters
import part.models as part_models import part.models as part_models
import stock.models import stock.models
import stock.serializers import stock.serializers
from common.serializers import ProjectCodeSerializer
from company.serializers import ( from company.serializers import (
AddressBriefSerializer, AddressBriefSerializer,
CompanyBriefSerializer, CompanyBriefSerializer,
@@ -46,7 +36,6 @@ from InvenTree.helpers import (
) )
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import ( from InvenTree.serializers import (
FilterableCharField,
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
@@ -141,6 +130,7 @@ class AbstractOrderSerializer(
source='contact', many=False, read_only=True, allow_null=True source='contact', many=False, read_only=True, allow_null=True
), ),
True, True,
prefetch_fields=['contact'],
) )
# Detail for responsible field # Detail for responsible field
@@ -149,26 +139,11 @@ class AbstractOrderSerializer(
source='responsible', read_only=True, allow_null=True, many=False source='responsible', read_only=True, allow_null=True, many=False
), ),
True, True,
prefetch_fields=['responsible'],
) )
project_code_label = enable_filter( project_code_label = common.filters.enable_project_label_filter()
FilterableCharField( project_code_detail = common.filters.enable_project_code_filter()
source='project_code.code',
read_only=True,
label='Project Code Label',
allow_null=True,
),
True,
filter_name='project_code_detail',
)
# Detail for project code field
project_code_detail = enable_filter(
ProjectCodeSerializer(
source='project_code', read_only=True, many=False, allow_null=True
),
True,
)
# Detail for address field # Detail for address field
address_detail = enable_filter( address_detail = enable_filter(
@@ -176,13 +151,10 @@ class AbstractOrderSerializer(
source='address', many=False, read_only=True, allow_null=True source='address', many=False, read_only=True, allow_null=True
), ),
True, True,
prefetch_fields=['address'],
) )
parameters = enable_filter( parameters = common.filters.enable_parameters_filter()
common.serializers.ParameterSerializer(many=True, read_only=True),
False,
filter_name='parameters',
)
# Boolean field indicating if this order is overdue (Note: must be annotated) # Boolean field indicating if this order is overdue (Note: must be annotated)
overdue = serializers.BooleanField(read_only=True, allow_null=True) overdue = serializers.BooleanField(read_only=True, allow_null=True)
@@ -317,23 +289,9 @@ class AbstractLineItemSerializer(FilterableSerializerMixin, serializers.Serializ
required=False, allow_null=True, label=_('Target Date') required=False, allow_null=True, label=_('Target Date')
) )
project_code_label = enable_filter( project_code_label = common.filters.enable_project_label_filter()
FilterableCharField(
source='project_code.code',
read_only=True,
label='Project Code Label',
allow_null=True,
),
True,
filter_name='project_code_detail',
)
project_code_detail = enable_filter( project_code_detail = common.filters.enable_project_code_filter()
ProjectCodeSerializer(
source='project_code', read_only=True, many=False, allow_null=True
),
True,
)
class AbstractExtraLineSerializer( class AbstractExtraLineSerializer(
@@ -369,24 +327,9 @@ class AbstractExtraLineSerializer(
price_currency = InvenTreeCurrencySerializer() price_currency = InvenTreeCurrencySerializer()
project_code_label = enable_filter( project_code_label = common.filters.enable_project_label_filter()
FilterableCharField(
source='project_code.code',
read_only=True,
label='Project Code Label',
allow_null=True,
),
True,
filter_name='project_code_detail',
)
# Detail for project code field project_code_detail = common.filters.enable_project_code_filter()
project_code_detail = enable_filter(
ProjectCodeSerializer(
source='project_code', read_only=True, many=False, allow_null=True
),
True,
)
class AbstractExtraLineMeta: class AbstractExtraLineMeta:
@@ -452,9 +395,6 @@ class PurchaseOrderSerializer(
""" """
queryset = AbstractOrderSerializer.annotate_queryset(queryset) queryset = AbstractOrderSerializer.annotate_queryset(queryset)
# Annotate parametric data
queryset = order.models.PurchaseOrder.annotate_parameters(queryset)
queryset = queryset.annotate( queryset = queryset.annotate(
completed_lines=SubqueryCount( completed_lines=SubqueryCount(
'lines', filter=Q(quantity__lte=F('received')) 'lines', filter=Q(quantity__lte=F('received'))
@@ -471,6 +411,8 @@ class PurchaseOrderSerializer(
) )
) )
queryset = queryset.prefetch_related('created_by')
return queryset return queryset
supplier_name = serializers.CharField( supplier_name = serializers.CharField(
@@ -480,7 +422,8 @@ class PurchaseOrderSerializer(
supplier_detail = enable_filter( supplier_detail = enable_filter(
CompanyBriefSerializer( CompanyBriefSerializer(
source='supplier', many=False, read_only=True, allow_null=True source='supplier', many=False, read_only=True, allow_null=True
) ),
prefetch_fields=['supplier'],
) )
@@ -612,27 +555,18 @@ class PurchaseOrderLineItemSerializer(
- "total_price" = purchase_price * quantity - "total_price" = purchase_price * quantity
- "overdue" status (boolean field) - "overdue" status (boolean field)
""" """
queryset = queryset.prefetch_related(
Prefetch(
'part__part',
queryset=part_models.Part.objects.annotate(
category_default_location=part_filters.annotate_default_location(
'category__'
)
).prefetch_related(None),
)
)
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'order', 'order',
'order__responsible', 'order__responsible',
'order__stock_items', 'order__stock_items',
'part',
'part__part',
'part__part__pricing_data',
'part__part__default_location',
'part__tags', 'part__tags',
'part__supplier', 'part__supplier',
'part__manufacturer_part', 'part__manufacturer_part',
'part__manufacturer_part__manufacturer', 'part__manufacturer_part__manufacturer',
'part__part__pricing_data',
'part__part__tags',
) )
queryset = queryset.annotate( queryset = queryset.annotate(
@@ -687,6 +621,7 @@ class PurchaseOrderLineItemSerializer(
PartBriefSerializer( PartBriefSerializer(
source='get_base_part', many=False, read_only=True, allow_null=True source='get_base_part', many=False, read_only=True, allow_null=True
), ),
False,
filter_name='part_detail', filter_name='part_detail',
) )
@@ -694,6 +629,7 @@ class PurchaseOrderLineItemSerializer(
SupplierPartSerializer( SupplierPartSerializer(
source='part', brief=True, many=False, read_only=True, allow_null=True source='part', brief=True, many=False, read_only=True, allow_null=True
), ),
False,
filter_name='part_detail', filter_name='part_detail',
) )
@@ -707,8 +643,12 @@ class PurchaseOrderLineItemSerializer(
default=True, default=True,
) )
destination_detail = stock.serializers.LocationBriefSerializer( destination_detail = enable_filter(
stock.serializers.LocationBriefSerializer(
source='get_destination', read_only=True, allow_null=True source='get_destination', read_only=True, allow_null=True
),
True,
prefetch_fields=['destination', 'order__destination'],
) )
purchase_price_currency = InvenTreeCurrencySerializer( purchase_price_currency = InvenTreeCurrencySerializer(
@@ -721,8 +661,16 @@ class PurchaseOrderLineItemSerializer(
) )
) )
build_order_detail = build.serializers.BuildSerializer( build_order_detail = enable_filter(
build.serializers.BuildSerializer(
source='build_order', read_only=True, allow_null=True, many=False source='build_order', read_only=True, allow_null=True, many=False
),
True,
prefetch_fields=[
'build_order__responsible',
'build_order__issued_by',
'build_order__part',
],
) )
merge_items = serializers.BooleanField( merge_items = serializers.BooleanField(
@@ -1098,9 +1046,6 @@ class SalesOrderSerializer(
""" """
queryset = AbstractOrderSerializer.annotate_queryset(queryset) queryset = AbstractOrderSerializer.annotate_queryset(queryset)
# Annotate parametric data
queryset = order.models.SalesOrder.annotate_parameters(queryset)
queryset = queryset.annotate( queryset = queryset.annotate(
completed_lines=SubqueryCount('lines', filter=Q(quantity__lte=F('shipped'))) completed_lines=SubqueryCount('lines', filter=Q(quantity__lte=F('shipped')))
) )
@@ -1128,7 +1073,8 @@ class SalesOrderSerializer(
customer_detail = enable_filter( customer_detail = enable_filter(
CompanyBriefSerializer( CompanyBriefSerializer(
source='customer', many=False, read_only=True, allow_null=True source='customer', many=False, read_only=True, allow_null=True
) ),
prefetch_fields=['customer'],
) )
shipments_count = serializers.IntegerField( shipments_count = serializers.IntegerField(
@@ -1277,15 +1223,26 @@ class SalesOrderLineItemSerializer(
order_detail = enable_filter( order_detail = enable_filter(
SalesOrderSerializer( SalesOrderSerializer(
source='order', many=False, read_only=True, allow_null=True source='order', many=False, read_only=True, allow_null=True
),
prefetch_fields=[
'order__created_by',
'order__responsible',
'order__address',
'order__project_code',
'order__contact',
],
) )
)
part_detail = enable_filter( part_detail = enable_filter(
PartBriefSerializer(source='part', many=False, read_only=True, allow_null=True) PartBriefSerializer(source='part', many=False, read_only=True, allow_null=True),
prefetch_fields=['part__pricing_data'],
) )
customer_detail = enable_filter( customer_detail = enable_filter(
CompanyBriefSerializer( CompanyBriefSerializer(
source='order.customer', many=False, read_only=True, allow_null=True source='order.customer', many=False, read_only=True, allow_null=True
) ),
prefetch_fields=['order__customer'],
) )
# Annotated fields # Annotated fields
@@ -1950,9 +1907,6 @@ class ReturnOrderSerializer(
"""Custom annotation for the serializer queryset.""" """Custom annotation for the serializer queryset."""
queryset = AbstractOrderSerializer.annotate_queryset(queryset) queryset = AbstractOrderSerializer.annotate_queryset(queryset)
# Annotate parametric data
queryset = order.models.ReturnOrder.annotate_parameters(queryset)
queryset = queryset.annotate( queryset = queryset.annotate(
completed_lines=SubqueryCount( completed_lines=SubqueryCount(
'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value) 'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value)
@@ -1974,7 +1928,8 @@ class ReturnOrderSerializer(
customer_detail = enable_filter( customer_detail = enable_filter(
CompanyBriefSerializer( CompanyBriefSerializer(
source='customer', many=False, read_only=True, allow_null=True source='customer', many=False, read_only=True, allow_null=True
) ),
prefetch_fields=['customer'],
) )
@@ -2134,7 +2089,14 @@ class ReturnOrderLineItemSerializer(
order_detail = enable_filter( order_detail = enable_filter(
ReturnOrderSerializer( ReturnOrderSerializer(
source='order', many=False, read_only=True, allow_null=True source='order', many=False, read_only=True, allow_null=True
) ),
prefetch_fields=[
'order__created_by',
'order__responsible',
'order__address',
'order__project_code',
'order__contact',
],
) )
quantity = serializers.FloatField( quantity = serializers.FloatField(
@@ -2144,7 +2106,8 @@ class ReturnOrderLineItemSerializer(
item_detail = enable_filter( item_detail = enable_filter(
stock.serializers.StockItemSerializer( stock.serializers.StockItemSerializer(
source='item', many=False, read_only=True, allow_null=True source='item', many=False, read_only=True, allow_null=True
) ),
prefetch_fields=['item__supplier_part'],
) )
part_detail = enable_filter( part_detail = enable_filter(

View File

@@ -1009,7 +1009,7 @@ class PartMixin(SerializerContextMixin):
"""Mixin class for Part API endpoints.""" """Mixin class for Part API endpoints."""
serializer_class = part_serializers.PartSerializer serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all() queryset = Part.objects.all().select_related('pricing_data')
starred_parts = None starred_parts = None
is_create = False is_create = False
@@ -1020,9 +1020,6 @@ class PartMixin(SerializerContextMixin):
queryset = part_serializers.PartSerializer.annotate_queryset(queryset) queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
if str2bool(self.request.query_params.get('price_breaks', True)):
queryset = queryset.prefetch_related('salepricebreaks')
return queryset return queryset
def get_serializer(self, *args, **kwargs): def get_serializer(self, *args, **kwargs):

View File

@@ -9,7 +9,7 @@ from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q from django.db.models import ExpressionWrapper, F, Q
from django.db.models.functions import Coalesce, Greatest from django.db.models.functions import Greatest
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -22,6 +22,7 @@ from sql_util.utils import SubqueryCount
from taggit.serializers import TagListSerializerField from taggit.serializers import TagListSerializerField
import common.currency import common.currency
import common.filters
import common.serializers import common.serializers
import company.models import company.models
import InvenTree.helpers import InvenTree.helpers
@@ -619,7 +620,6 @@ class PartSerializer(
'required_for_build_orders', 'required_for_build_orders',
'required_for_sales_orders', 'required_for_sales_orders',
'stock_item_count', 'stock_item_count',
'suppliers',
'total_in_stock', 'total_in_stock',
'external_stock', 'external_stock',
'unallocated_stock', 'unallocated_stock',
@@ -680,10 +680,6 @@ class PartSerializer(
Performing database queries as efficiently as possible, to reduce database trips. Performing database queries as efficiently as possible, to reduce database trips.
""" """
queryset = queryset.prefetch_related('category', 'default_location')
queryset = Part.annotate_parameters(queryset)
# Annotate with the total number of revisions # Annotate with the total number of revisions
queryset = queryset.annotate(revision_count=SubqueryCount('revisions')) queryset = queryset.annotate(revision_count=SubqueryCount('revisions'))
@@ -708,15 +704,6 @@ class PartSerializer(
scheduled_to_build=part_filters.annotate_scheduled_to_build_quantity() scheduled_to_build=part_filters.annotate_scheduled_to_build_quantity()
) )
# Annotate with the number of 'suppliers'
queryset = queryset.annotate(
suppliers=Coalesce(
SubqueryCount('supplier_parts'),
Decimal(0),
output_field=models.DecimalField(),
)
)
queryset = queryset.annotate( queryset = queryset.annotate(
ordering=part_filters.annotate_on_order_quantity(), ordering=part_filters.annotate_on_order_quantity(),
in_stock=part_filters.annotate_total_stock(), in_stock=part_filters.annotate_total_stock(),
@@ -775,7 +762,8 @@ class PartSerializer(
category_detail = enable_filter( category_detail = enable_filter(
CategorySerializer( CategorySerializer(
source='category', many=False, read_only=True, allow_null=True source='category', many=False, read_only=True, allow_null=True
) ),
prefetch_fields=['category'],
) )
category_path = enable_filter( category_path = enable_filter(
@@ -786,6 +774,7 @@ class PartSerializer(
allow_null=True, allow_null=True,
), ),
filter_name='path_detail', filter_name='path_detail',
prefetch_fields=['category'],
) )
default_location_detail = enable_filter( default_location_detail = enable_filter(
@@ -793,6 +782,7 @@ class PartSerializer(
source='default_location', many=False, read_only=True, allow_null=True source='default_location', many=False, read_only=True, allow_null=True
), ),
filter_name='location_detail', filter_name='location_detail',
prefetch_fields=['default_location'],
) )
category_name = serializers.CharField( category_name = serializers.CharField(
@@ -860,10 +850,6 @@ class PartSerializer(
read_only=True, allow_null=True, label=_('Revisions') read_only=True, allow_null=True, label=_('Revisions')
) )
suppliers = serializers.IntegerField(
read_only=True, allow_null=True, label=_('Suppliers')
)
total_in_stock = serializers.FloatField( total_in_stock = serializers.FloatField(
read_only=True, allow_null=True, label=_('Total Stock') read_only=True, allow_null=True, label=_('Total Stock')
) )
@@ -922,13 +908,7 @@ class PartSerializer(
filter_name='pricing', filter_name='pricing',
) )
parameters = enable_filter( parameters = common.filters.enable_parameters_filter()
common.serializers.ParameterSerializer(
many=True, read_only=True, allow_null=True
),
False,
filter_name='parameters',
)
price_breaks = enable_filter( price_breaks = enable_filter(
PartSalePriceSerializer( PartSalePriceSerializer(
@@ -936,6 +916,7 @@ class PartSerializer(
), ),
False, False,
filter_name='price_breaks', filter_name='price_breaks',
prefetch_fields=['salepricebreaks'],
) )
# Extra fields used only for creation of a new Part instance # Extra fields used only for creation of a new Part instance
@@ -963,6 +944,7 @@ class PartSerializer(
copy_category_parameters = serializers.BooleanField( copy_category_parameters = serializers.BooleanField(
default=True, default=True,
required=False, required=False,
write_only=True,
label=_('Copy Category Parameters'), label=_('Copy Category Parameters'),
help_text=_('Copy parameter templates from selected part category'), help_text=_('Copy parameter templates from selected part category'),
) )

View File

@@ -475,9 +475,7 @@ class StockItemSerializer(
"""Add some extra annotations to the queryset, performing database queries as efficiently as possible.""" """Add some extra annotations to the queryset, performing database queries as efficiently as possible."""
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'location', 'location',
'allocations',
'sales_order', 'sales_order',
'sales_order_allocations',
'purchase_order', 'purchase_order',
Prefetch( Prefetch(
'part', 'part',
@@ -489,25 +487,15 @@ class StockItemSerializer(
), ),
'parent', 'parent',
'part__category', 'part__category',
'part__supplier_parts',
'part__supplier_parts__purchase_order_line_items',
'part__pricing_data', 'part__pricing_data',
'part__tags',
'supplier_part', 'supplier_part',
'supplier_part__part',
'supplier_part__supplier',
'supplier_part__manufacturer_part', 'supplier_part__manufacturer_part',
'supplier_part__manufacturer_part__manufacturer',
'supplier_part__manufacturer_part__tags',
'supplier_part__purchase_order_line_items',
'supplier_part__tags',
'test_results',
'customer', 'customer',
'belongs_to', 'belongs_to',
'sales_order', 'sales_order',
'consumed_by', 'consumed_by',
'tags', 'tags',
) ).select_related('part')
# Annotate the queryset with the total allocated to sales orders # Annotate the queryset with the total allocated to sales orders
queryset = queryset.annotate( queryset = queryset.annotate(
@@ -586,7 +574,14 @@ class StockItemSerializer(
read_only=True, read_only=True,
allow_null=True, allow_null=True,
), ),
True, False,
prefetch_fields=[
'supplier_part__supplier',
'supplier_part__manufacturer_part__manufacturer',
'supplier_part__manufacturer_part__tags',
'supplier_part__purchase_order_line_items',
'supplier_part__tags',
],
) )
part_detail = enable_filter( part_detail = enable_filter(
@@ -604,13 +599,20 @@ class StockItemSerializer(
read_only=True, read_only=True,
allow_null=True, allow_null=True,
), ),
True, False,
prefetch_fields=['location'],
) )
tests = enable_filter( tests = enable_filter(
StockItemTestResultSerializer( StockItemTestResultSerializer(
source='test_results', many=True, read_only=True, allow_null=True source='test_results', many=True, read_only=True, allow_null=True
) ),
False,
prefetch_fields=[
'test_results',
'test_results__user',
'test_results__template',
],
) )
quantity = InvenTreeDecimalField() quantity = InvenTreeDecimalField()

View File

@@ -866,7 +866,9 @@ class StockItemListTest(StockAPITestCase):
excluded_headers = ['metadata'] excluded_headers = ['metadata']
with self.export_data(self.list_url) as data_file: filters = {}
with self.export_data(self.list_url, filters) as data_file:
self.process_csv( self.process_csv(
data_file, data_file,
required_cols=required_headers, required_cols=required_headers,
@@ -875,9 +877,10 @@ class StockItemListTest(StockAPITestCase):
) )
# Now, add a filter to the results # Now, add a filter to the results
with self.export_data( filters['location'] = 1
self.list_url, {'location': 1, 'cascade': True} filters['cascade'] = True
) as data_file:
with self.export_data(self.list_url, filters) as data_file:
data = self.process_csv(data_file, required_rows=9) data = self.process_csv(data_file, required_rows=9)
for row in data: for row in data:

View File

@@ -1,7 +1,10 @@
"""Helper functions for user permission checks.""" """Helper functions for user permission checks."""
from typing import Optional
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.db.models.query import Prefetch, QuerySet
import InvenTree.cache import InvenTree.cache
from users.ruleset import RULESET_CHANGE_INHERIT, get_ruleset_ignore, get_ruleset_models from users.ruleset import RULESET_CHANGE_INHERIT, get_ruleset_ignore, get_ruleset_models
@@ -55,8 +58,26 @@ def split_permission(app: str, perm: str) -> tuple[str, str]:
return perm, model return perm, model
def prefetch_rule_sets(user) -> QuerySet:
"""Return a queryset of groups with prefetched rule sets for the given user.
Arguments:
user: The user object
Returns:
QuerySet: The queryset of groups with prefetched rule sets
"""
return user.groups.all().prefetch_related(
Prefetch('rule_sets', to_attr='prefetched_rule_sets')
)
def check_user_role( def check_user_role(
user: User, role: str, permission: str, allow_inactive: bool = False user: User,
role: str,
permission: str,
allow_inactive: bool = False,
groups: Optional[QuerySet] = None,
) -> bool: ) -> bool:
"""Check if a user has a particular role:permission combination. """Check if a user has a particular role:permission combination.
@@ -65,6 +86,7 @@ def check_user_role(
role: The role to check (e.g. 'part' / 'stock') role: The role to check (e.g. 'part' / 'stock')
permission: The permission to check (e.g. 'view' / 'delete') permission: The permission to check (e.g. 'view' / 'delete')
allow_inactive: If False, disallow inactive users from having permissions allow_inactive: If False, disallow inactive users from having permissions
groups: Optional cached queryset of groups to check (defaults to user's groups)
Returns: Returns:
bool: True if the user has the specified role:permission combination bool: True if the user has the specified role:permission combination
@@ -90,8 +112,10 @@ def check_user_role(
# Default for no match # Default for no match
result = False result = False
for group in user.groups.all(): groups = groups or prefetch_rule_sets(user)
for rule in group.rule_sets.all():
for group in groups:
for rule in group.prefetched_rule_sets:
if rule.name == role: if rule.name == role:
# Check if the rule has the specified permission # Check if the rule has the specified permission
# e.g. "view" role maps to "can_view" attribute # e.g. "view" role maps to "can_view" attribute

View File

@@ -19,7 +19,7 @@ from InvenTree.serializers import (
) )
from .models import ApiToken, Owner, RuleSet, UserProfile from .models import ApiToken, Owner, RuleSet, UserProfile
from .permissions import check_user_role from .permissions import check_user_role, prefetch_rule_sets
from .ruleset import RULESET_CHOICES, RULESET_PERMISSIONS, RuleSetEnum from .ruleset import RULESET_CHOICES, RULESET_PERMISSIONS, RuleSetEnum
@@ -83,13 +83,16 @@ class RoleSerializer(InvenTreeModelSerializer):
"""Roles associated with the user.""" """Roles associated with the user."""
roles = {} roles = {}
# Cache the 'groups' queryset for the user
groups = prefetch_rule_sets(user)
for ruleset in RULESET_CHOICES: for ruleset in RULESET_CHOICES:
role, _text = ruleset role, _text = ruleset
permissions = [] permissions = []
for permission in RULESET_PERMISSIONS: for permission in RULESET_PERMISSIONS:
if check_user_role(user, role, permission): if check_user_role(user, role, permission, groups=groups):
permissions.append(permission) permissions.append(permission)
if len(permissions) > 0: if len(permissions) > 0:

View File

@@ -972,8 +972,10 @@ export default function BuildLineTable({
...params, ...params,
build: build.pk, build: build.pk,
assembly_detail: false, assembly_detail: false,
bom_item_detail: true,
category_detail: true, category_detail: true,
part_detail: true part_detail: true,
allocations: true
}, },
tableActions: tableActions, tableActions: tableActions,
tableFilters: tableFilters, tableFilters: tableFilters,

View File

@@ -206,7 +206,9 @@ export default function BuildOutputTable({
.get(apiUrl(ApiEndpoints.build_line_list), { .get(apiUrl(ApiEndpoints.build_line_list), {
params: { params: {
build: buildId, build: buildId,
tracked: true tracked: true,
bom_item_detail: true,
allocations: true
} }
}) })
.then((response) => response.data); .then((response) => response.data);

View File

@@ -165,7 +165,8 @@ export default function PartBuildAllocationsTable({
project_code_detail: true, project_code_detail: true,
assembly_detail: true, assembly_detail: true,
build_detail: true, build_detail: true,
order_outstanding: true order_outstanding: true,
allocations: true
}, },
enableColumnSwitching: true, enableColumnSwitching: true,
enableSearch: false, enableSearch: false,

View File

@@ -439,7 +439,8 @@ export function PurchaseOrderLineItemTable({
params: { params: {
...params, ...params,
order: orderId, order: orderId,
part_detail: true part_detail: true,
destination_detail: true
}, },
rowActions: rowActions, rowActions: rowActions,
tableActions: tableActions, tableActions: tableActions,

View File

@@ -419,7 +419,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
.getByRole('cell', { name: /Choose Location/ }) .getByRole('cell', { name: /Choose Location/ })
.getByText('Room 101') .getByText('Room 101')
.waitFor(); .waitFor();
await page.getByText('Mechanical Lab').waitFor();
await page.getByText('Mechanical Lab').first().waitFor();
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();