From ba7b776257b91afc7b8875bb9e4b02045929a287 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 16 Dec 2025 14:46:17 +1100 Subject: [PATCH] [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 --- .../InvenTree/InvenTree/api_version.py | 8 +- src/backend/InvenTree/InvenTree/mixins.py | 13 ++ .../InvenTree/InvenTree/serializers.py | 49 ++++- src/backend/InvenTree/build/api.py | 42 +---- src/backend/InvenTree/build/serializers.py | 173 +++++++----------- src/backend/InvenTree/common/api.py | 8 +- src/backend/InvenTree/common/filters.py | 70 +++++++ src/backend/InvenTree/company/api.py | 13 +- src/backend/InvenTree/company/serializers.py | 138 ++++++-------- src/backend/InvenTree/company/test_api.py | 64 +++++++ src/backend/InvenTree/data_exporter/mixins.py | 8 +- src/backend/InvenTree/order/api.py | 18 +- src/backend/InvenTree/order/models.py | 15 +- src/backend/InvenTree/order/serializers.py | 169 +++++++---------- src/backend/InvenTree/part/api.py | 5 +- src/backend/InvenTree/part/serializers.py | 36 +--- src/backend/InvenTree/stock/serializers.py | 34 ++-- src/backend/InvenTree/stock/test_api.py | 11 +- src/backend/InvenTree/users/permissions.py | 30 ++- src/backend/InvenTree/users/serializers.py | 7 +- .../src/tables/build/BuildLineTable.tsx | 4 +- .../src/tables/build/BuildOutputTable.tsx | 4 +- .../tables/part/PartBuildAllocationsTable.tsx | 3 +- .../purchasing/PurchaseOrderLineItemTable.tsx | 3 +- .../tests/pages/pui_purchase_order.spec.ts | 3 +- 25 files changed, 508 insertions(+), 420 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 91e54fa77c..a1e04531d4 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,17 @@ """InvenTree API version information.""" # 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.""" 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 - Remove duplicate "address" field on the Company API endpoint - Make "primary_address" field optional on the Company API endpoint diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index ac394f1e6d..498b201872 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -258,6 +258,19 @@ class OutputOptionsMixin: 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: """Mixin to add context to serializer.""" diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 074b8ce6f7..e9034344b5 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models +from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _ from djmoney.contrib.django_rest_framework.fields import MoneyField @@ -44,11 +45,15 @@ class FilterableSerializerField: is_filterable = None is_filterable_vals = {} + # Options for automatic queryset prefetching + prefetch_fields: Optional[list[str]] = None + def __init__(self, *args, **kwargs): """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_vals = kwargs.pop('is_filterable_vals', {}) + 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) @@ -57,6 +62,7 @@ def enable_filter( default_include: bool = False, filter_name: Optional[str] = None, filter_by_query: bool = True, + prefetch_fields: Optional[list[str]] = None, ): """Decorator for marking a serializer field as filterable. @@ -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. 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. @@ -84,6 +91,10 @@ def enable_filter( 'filter_name': filter_name if filter_name else func.field_name, 'filter_by_query': filter_by_query, } + + # Attach queryset prefetching information + func._kwargs['prefetch_fields'] = prefetch_fields + return func @@ -113,6 +124,34 @@ class FilterableSerializerMixin: super().__init__(*args, **kwargs) 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: """Gather filterable fields through introspection.""" # Fast exit if this has already been done or would not have any effect @@ -168,6 +207,10 @@ class FilterableSerializerMixin: ): 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) for k, v in self.filter_target_values.items(): # See `enable_filter` where` is_filterable and is_filterable_vals are set diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 6ecb34b222..d7b8692bab 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -319,15 +319,6 @@ class BuildMixin: queryset = build.serializers.BuildSerializer.annotate_queryset(queryset) - queryset = queryset.prefetch_related( - 'responsible', - 'issued_by', - 'build_lines', - 'part', - 'part__pricing_data', - 'project_code', - ) - return queryset @@ -568,26 +559,27 @@ class BuildLineOutputOptions(OutputConfiguration): InvenTreeOutputOption( 'bom_item_detail', description='Include detailed information about the BOM item linked to this build line.', - default=True, + default=False, ), InvenTreeOutputOption( 'assembly_detail', description='Include brief details of the assembly (parent part) related to the BOM item in this build line.', - default=True, + default=False, ), InvenTreeOutputOption( 'part_detail', description='Include detailed information about the specific part being built or consumed in this build line.', - default=True, + default=False, ), InvenTreeOutputOption( 'build_detail', description='Include detailed information about the associated build order.', + default=False, ), InvenTreeOutputOption( 'allocations', 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('stock_detail'), InvenTreeOutputOption('build_detail'), + InvenTreeOutputOption('supplier_part_detail'), ] @@ -927,26 +920,9 @@ class BuildItemList( """Override the queryset method, to perform custom prefetch.""" queryset = super().get_queryset() - queryset = queryset.select_related( - 'build_line', - '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') + queryset = queryset.select_related('install_into').prefetch_related( + 'build_line', 'build_line__build', 'build_line__bom_item' + ) return queryset diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 9d7f80b9e1..97324bef95 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -22,18 +22,16 @@ from rest_framework import serializers from rest_framework.serializers import ValidationError import build.tasks -import common.serializers +import common.filters import common.settings import company.serializers import InvenTree.helpers import part.filters import part.serializers as part_serializers -from common.serializers import ProjectCodeSerializer from common.settings import get_global_setting from generic.states.fields import InvenTreeCustomStatusSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( - FilterableCharField, FilterableSerializerMixin, InvenTreeDecimalField, InvenTreeModelSerializer, @@ -124,13 +122,10 @@ class BuildSerializer( part_detail = enable_filter( part_serializers.PartBriefSerializer(source='part', many=False, read_only=True), True, + prefetch_fields=['part', 'part__category', 'part__pricing_data'], ) - parameters = enable_filter( - common.serializers.ParameterSerializer(many=True, read_only=True), - False, - filter_name='parameters', - ) + parameters = common.filters.enable_parameters_filter() part_name = serializers.CharField( source='part.name', read_only=True, label=_('Part Name') @@ -144,34 +139,21 @@ class BuildSerializer( UserSerializer(source='issued_by', read_only=True), True, filter_name='user_detail', + prefetch_fields=['issued_by'], ) responsible_detail = enable_filter( OwnerSerializer(source='responsible', read_only=True, allow_null=True), True, filter_name='user_detail', + prefetch_fields=['responsible'], ) barcode_hash = serializers.CharField(read_only=True) - project_code_label = enable_filter( - FilterableCharField( - source='project_code.code', - read_only=True, - label=_('Project Code Label'), - allow_null=True, - ), - True, - filter_name='project_code_detail', - ) + project_code_label = common.filters.enable_project_label_filter() - project_code_detail = enable_filter( - ProjectCodeSerializer( - source='project_code', many=False, read_only=True, allow_null=True - ), - True, - filter_name='project_code_detail', - ) + project_code_detail = common.filters.enable_project_code_filter() @staticmethod def annotate_queryset(queryset): @@ -192,8 +174,6 @@ class BuildSerializer( ) ) - queryset = Build.annotate_parameters(queryset) - return queryset def __init__(self, *args, **kwargs): @@ -1240,6 +1220,7 @@ class BuildItemSerializer( pricing=False, ), True, + prefetch_fields=['stock_item__part'], ) stock_item_detail = enable_filter( @@ -1255,6 +1236,13 @@ class BuildItemSerializer( ), True, 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( @@ -1269,6 +1257,7 @@ class BuildItemSerializer( allow_null=True, ), True, + prefetch_fields=['stock_item__location', 'stock_item__location__tags'], ) build_detail = enable_filter( @@ -1280,15 +1269,32 @@ class BuildItemSerializer( allow_null=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( - label=_('Supplier Part'), - source='stock_item.supplier_part', - many=False, - read_only=True, - allow_null=True, - brief=True, + supplier_part_detail = enable_filter( + company.serializers.SupplierPartSerializer( + label=_('Supplier Part'), + source='stock_item.supplier_part', + many=False, + read_only=True, + allow_null=True, + brief=True, + ), + False, + 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')) @@ -1373,7 +1379,18 @@ class BuildLineSerializer( ) 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 @@ -1417,7 +1434,7 @@ class BuildLineSerializer( part_detail=False, can_build=False, ), - True, + False, ) assembly_detail = enable_filter( @@ -1429,7 +1446,8 @@ class BuildLineSerializer( allow_null=True, pricing=False, ), - True, + False, + prefetch_fields=['bom_item__part', 'bom_item__part__pricing_data'], ) part_detail = enable_filter( @@ -1440,7 +1458,8 @@ class BuildLineSerializer( read_only=True, pricing=False, ), - True, + False, + prefetch_fields=['bom_item__sub_part', 'bom_item__sub_part__pricing_data'], ) category_detail = enable_filter( @@ -1452,6 +1471,7 @@ class BuildLineSerializer( allow_null=True, ), False, + prefetch_fields=['bom_item__sub_part__category'], ) build_detail = enable_filter( @@ -1517,77 +1537,16 @@ class BuildLineSerializer( '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 - queryset = ( - 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__metadata', - 'build__responsible', - '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__variant_of', - '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__variant_of', - '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', - ) + queryset = queryset.defer( + 'build__notes', + 'build__metadata', + 'bom_item__metadata', + 'bom_item__part__notes', + 'bom_item__part__metadata', + 'bom_item__sub_part__notes', + 'bom_item__sub_part__metadata', ) # Annotate the "allocated" quantity diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index ec64f79d87..ae972f6ed1 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -848,7 +848,9 @@ class ParameterTemplateFilter(FilterSet): class ParameterTemplateMixin: """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 permission_classes = [IsAuthenticatedOrReadScope] @@ -891,7 +893,9 @@ class ParameterFilter(FilterSet): class ParameterMixin: """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 permission_classes = [IsAuthenticatedOrReadScope] diff --git a/src/backend/InvenTree/common/filters.py b/src/backend/InvenTree/common/filters.py index 98237648d6..b75aea3a95 100644 --- a/src/backend/InvenTree/common/filters.py +++ b/src/backend/InvenTree/common/filters.py @@ -17,9 +17,11 @@ from django.db.models import ( When, ) from django.db.models.query import QuerySet +from django.utils.translation import gettext_lazy as _ import InvenTree.conversion import InvenTree.helpers +import InvenTree.serializers 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', ) + + +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', + ], + ) diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 15406ca71a..ba119ce8e6 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -178,11 +178,7 @@ class ManufacturerPartMixin(SerializerContextMixin): """Return annotated queryset for the ManufacturerPart list endpoint.""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'part', 'manufacturer', 'supplier_parts', 'tags' - ) - - queryset = ManufacturerPart.annotate_parameters(queryset) + queryset = queryset.prefetch_related('supplier_parts', 'tags') return queryset @@ -304,13 +300,18 @@ class SupplierPartOutputOptions(OutputConfiguration): InvenTreeOutputOption( description='Include detailed information about the Supplier in the response', flag='supplier_detail', - default=True, + default=False, ), InvenTreeOutputOption( description='Include detailed information about the Manufacturer in the response', flag='manufacturer_detail', default=False, ), + InvenTreeOutputOption( + flag='manufacturer_part_detail', + description='Include detailed information about the linked ManufacturerPart in the response', + default=False, + ), InvenTreeOutputOption( description='Format the output with a more readable (pretty) name', flag='pretty', diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 67fbfe763e..eb4dc0f229 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -10,7 +10,7 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount from taggit.serializers import TagListSerializerField -import common.serializers +import common.filters import company.filters import part.filters import part.serializers as part_serializers @@ -163,22 +163,19 @@ class CompanySerializer( 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( 'addresses', queryset=Address.objects.filter(primary=True), 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) @@ -195,11 +192,7 @@ class CompanySerializer( help_text=_('Default currency used for this supplier'), required=True ) - parameters = enable_filter( - common.serializers.ParameterSerializer(many=True, read_only=True), - False, - filter_name='parameters', - ) + parameters = common.filters.enable_parameters_filter() def save(self): """Save the Company instance.""" @@ -269,24 +262,14 @@ class ManufacturerPartSerializer( tags = TagListSerializerField(required=False) - parameters = enable_filter( - common.serializers.ParameterSerializer(many=True, read_only=True), - False, - filter_name='parameters', - ) + parameters = common.filters.enable_parameters_filter() part_detail = enable_filter( part_serializers.PartBriefSerializer( source='part', many=False, read_only=True, allow_null=True ), True, - ) - - manufacturer_detail = enable_filter( - CompanyBriefSerializer( - source='manufacturer', many=False, read_only=True, allow_null=True - ), - True, + prefetch_fields=['part'], ) pretty_name = enable_filter( @@ -297,6 +280,14 @@ class ManufacturerPartSerializer( 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( FilterableSerializerMixin, InvenTreeModelSerializer @@ -399,33 +390,13 @@ class SupplierPartSerializer( # Check if 'available' quantity was supplied self.has_available_quantity = 'available' in kwargs.get('data', {}) - # TODO INVE-T1 support complex filters 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) if isGeneratingSchema(): 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: self.fields.pop('tags') self.fields.pop('available') @@ -455,46 +426,61 @@ class SupplierPartSerializer( ), False, filter_name='price_breaks', + prefetch_fields=['pricebreaks'], ) - parameters = enable_filter( - common.serializers.ParameterSerializer(many=True, read_only=True), + parameters = common.filters.enable_parameters_filter() + + part_detail = enable_filter( + part_serializers.PartBriefSerializer( + label=_('Part'), source='part', many=False, read_only=True, allow_null=True + ), False, - filter_name='parameters', + prefetch_fields=['part'], ) - part_detail = part_serializers.PartBriefSerializer( - label=_('Part'), source='part', many=False, read_only=True, allow_null=True + supplier_detail = enable_filter( + CompanyBriefSerializer( + label=_('Supplier'), + source='supplier', + many=False, + read_only=True, + allow_null=True, + ), + False, + prefetch_fields=['supplier'], ) - supplier_detail = CompanyBriefSerializer( - label=_('Supplier'), - source='supplier', - many=False, - read_only=True, - allow_null=True, + manufacturer_detail = enable_filter( + CompanyBriefSerializer( + label=_('Manufacturer'), + source='manufacturer_part.manufacturer', + many=False, + read_only=True, + allow_null=True, + ), + False, + prefetch_fields=['manufacturer_part__manufacturer'], ) - manufacturer_detail = CompanyBriefSerializer( - label=_('Manufacturer'), - source='manufacturer_part.manufacturer', - many=False, - read_only=True, - allow_null=True, + pretty_name = enable_filter( + FilterableCharField(read_only=True, allow_null=True), filter_name='pretty' ) - pretty_name = serializers.CharField(read_only=True, allow_null=True) - supplier = serializers.PrimaryKeyRelatedField( label=_('Supplier'), queryset=Company.objects.filter(is_supplier=True) ) - manufacturer_part_detail = ManufacturerPartSerializer( - label=_('Manufacturer Part'), - source='manufacturer_part', - part_detail=False, - read_only=True, - allow_null=True, + manufacturer_part_detail = enable_filter( + ManufacturerPartSerializer( + label=_('Manufacturer Part'), + source='manufacturer_part', + part_detail=False, + read_only=True, + allow_null=True, + ), + False, + prefetch_fields=['manufacturer_part'], ) MPN = serializers.CharField( @@ -511,15 +497,13 @@ class SupplierPartSerializer( Fields: 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( on_order=company.filters.annotate_on_order_quantity() ) - queryset = SupplierPart.annotate_parameters(queryset) + queryset = queryset.prefetch_related('supplier', 'manufacturer_part') return queryset diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 5e5ffd372f..3599160d93 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -210,6 +210,34 @@ class CompanyTest(InvenTreeAPITestCase): 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): """Tests for the Contact models.""" @@ -682,6 +710,42 @@ class SupplierPartTest(InvenTreeAPITestCase): for result in response.data: 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): """Unit tests for the various metadata endpoints of API.""" diff --git a/src/backend/InvenTree/data_exporter/mixins.py b/src/backend/InvenTree/data_exporter/mixins.py index 33077354ff..316dfdfbce 100644 --- a/src/backend/InvenTree/data_exporter/mixins.py +++ b/src/backend/InvenTree/data_exporter/mixins.py @@ -53,7 +53,7 @@ class DataExportSerializerMixin: Determine if the serializer is being used for data export, 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) @@ -264,10 +264,8 @@ class DataExportViewMixin: exporting = kwargs.pop('exporting', None) if exporting is None: - exporting = ( - self.request.method.lower() in ['options', 'get'] - and self.is_exporting() - ) + method = str(getattr(self.request, 'method', '')).lower() + exporting = method in ['options', 'get'] and self.is_exporting() if exporting: # Override kwargs when initializing the DataExportOptionsSerializer diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index ee2fa7a4c9..a81e338084 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -369,12 +369,10 @@ class PurchaseOrderMixin(SerializerContextMixin): """Return the annotated queryset for this endpoint.""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'supplier', 'project_code', 'lines', 'responsible' - ) - queryset = serializers.PurchaseOrderSerializer.annotate_queryset(queryset) + queryset = queryset.prefetch_related('supplier', 'created_by') + return queryset @@ -833,9 +831,7 @@ class SalesOrderMixin(SerializerContextMixin): """Return annotated queryset for this endpoint.""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'customer', 'responsible', 'project_code', 'lines' - ) + queryset = queryset.prefetch_related('customer', 'created_by') queryset = serializers.SalesOrderSerializer.annotate_queryset(queryset) @@ -1018,17 +1014,13 @@ class SalesOrderLineItemMixin(SerializerContextMixin): queryset = queryset.prefetch_related( 'part', - 'part__stock_items', 'allocations', 'allocations__shipment', 'allocations__item__part', 'allocations__item__location', 'order', - 'order__stock_items', ) - queryset = queryset.select_related('part__pricing_data') - queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset) return queryset @@ -1510,9 +1502,7 @@ class ReturnOrderMixin(SerializerContextMixin): """Return annotated queryset for this endpoint.""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related( - 'customer', 'lines', 'project_code', 'responsible' - ) + queryset = queryset.prefetch_related('customer', 'created_by') queryset = serializers.ReturnOrderSerializer.annotate_queryset(queryset) diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index bf699af334..d159b988eb 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -1989,17 +1989,16 @@ class PurchaseOrderLineItem(OrderLineItem): def get_destination(self): """Show where the line item is or should be placed. - NOTE: If a line item gets split when received, only an arbitrary - stock items location will be reported as the location for the - entire line. + 1. If a destination is specified against this line item, return that. + 2. If a destination is specified against the PurchaseOrderPart, return that. + 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: return self.destination + + if self.order.destination: + return self.order.destination + if self.part and self.part.part and self.part.part.default_location: return self.part.part.default_location diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index caac022c91..0bdc90daa1 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -4,16 +4,7 @@ from decimal import Decimal from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models, transaction -from django.db.models import ( - BooleanField, - Case, - ExpressionWrapper, - F, - Prefetch, - Q, - Value, - When, -) +from django.db.models import BooleanField, Case, ExpressionWrapper, F, Q, Value, When from django.db.models.functions import Coalesce, Greatest 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 import build.serializers -import common.serializers +import common.filters import order.models import part.filters as part_filters import part.models as part_models import stock.models import stock.serializers -from common.serializers import ProjectCodeSerializer from company.serializers import ( AddressBriefSerializer, CompanyBriefSerializer, @@ -46,7 +36,6 @@ from InvenTree.helpers import ( ) from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( - FilterableCharField, FilterableSerializerMixin, InvenTreeCurrencySerializer, InvenTreeDecimalField, @@ -141,6 +130,7 @@ class AbstractOrderSerializer( source='contact', many=False, read_only=True, allow_null=True ), True, + prefetch_fields=['contact'], ) # Detail for responsible field @@ -149,26 +139,11 @@ class AbstractOrderSerializer( source='responsible', read_only=True, allow_null=True, many=False ), True, + prefetch_fields=['responsible'], ) - project_code_label = enable_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 = enable_filter( - ProjectCodeSerializer( - source='project_code', read_only=True, many=False, allow_null=True - ), - True, - ) + project_code_label = common.filters.enable_project_label_filter() + project_code_detail = common.filters.enable_project_code_filter() # Detail for address field address_detail = enable_filter( @@ -176,13 +151,10 @@ class AbstractOrderSerializer( source='address', many=False, read_only=True, allow_null=True ), True, + prefetch_fields=['address'], ) - parameters = enable_filter( - common.serializers.ParameterSerializer(many=True, read_only=True), - False, - filter_name='parameters', - ) + parameters = common.filters.enable_parameters_filter() # Boolean field indicating if this order is overdue (Note: must be annotated) 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') ) - project_code_label = enable_filter( - FilterableCharField( - source='project_code.code', - read_only=True, - label='Project Code Label', - allow_null=True, - ), - True, - filter_name='project_code_detail', - ) + project_code_label = common.filters.enable_project_label_filter() - project_code_detail = enable_filter( - ProjectCodeSerializer( - source='project_code', read_only=True, many=False, allow_null=True - ), - True, - ) + project_code_detail = common.filters.enable_project_code_filter() class AbstractExtraLineSerializer( @@ -369,24 +327,9 @@ class AbstractExtraLineSerializer( price_currency = InvenTreeCurrencySerializer() - project_code_label = enable_filter( - FilterableCharField( - source='project_code.code', - read_only=True, - label='Project Code Label', - allow_null=True, - ), - True, - filter_name='project_code_detail', - ) + project_code_label = common.filters.enable_project_label_filter() - # Detail for project code field - project_code_detail = enable_filter( - ProjectCodeSerializer( - source='project_code', read_only=True, many=False, allow_null=True - ), - True, - ) + project_code_detail = common.filters.enable_project_code_filter() class AbstractExtraLineMeta: @@ -452,9 +395,6 @@ class PurchaseOrderSerializer( """ queryset = AbstractOrderSerializer.annotate_queryset(queryset) - # Annotate parametric data - queryset = order.models.PurchaseOrder.annotate_parameters(queryset) - queryset = queryset.annotate( completed_lines=SubqueryCount( 'lines', filter=Q(quantity__lte=F('received')) @@ -471,6 +411,8 @@ class PurchaseOrderSerializer( ) ) + queryset = queryset.prefetch_related('created_by') + return queryset supplier_name = serializers.CharField( @@ -480,7 +422,8 @@ class PurchaseOrderSerializer( supplier_detail = enable_filter( CompanyBriefSerializer( source='supplier', many=False, read_only=True, allow_null=True - ) + ), + prefetch_fields=['supplier'], ) @@ -612,27 +555,18 @@ class PurchaseOrderLineItemSerializer( - "total_price" = purchase_price * quantity - "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( 'order', 'order__responsible', 'order__stock_items', + 'part', + 'part__part', + 'part__part__pricing_data', + 'part__part__default_location', 'part__tags', 'part__supplier', 'part__manufacturer_part', 'part__manufacturer_part__manufacturer', - 'part__part__pricing_data', - 'part__part__tags', ) queryset = queryset.annotate( @@ -687,6 +621,7 @@ class PurchaseOrderLineItemSerializer( PartBriefSerializer( source='get_base_part', many=False, read_only=True, allow_null=True ), + False, filter_name='part_detail', ) @@ -694,6 +629,7 @@ class PurchaseOrderLineItemSerializer( SupplierPartSerializer( source='part', brief=True, many=False, read_only=True, allow_null=True ), + False, filter_name='part_detail', ) @@ -707,8 +643,12 @@ class PurchaseOrderLineItemSerializer( default=True, ) - destination_detail = stock.serializers.LocationBriefSerializer( - source='get_destination', read_only=True, allow_null=True + destination_detail = enable_filter( + stock.serializers.LocationBriefSerializer( + source='get_destination', read_only=True, allow_null=True + ), + True, + prefetch_fields=['destination', 'order__destination'], ) purchase_price_currency = InvenTreeCurrencySerializer( @@ -721,8 +661,16 @@ class PurchaseOrderLineItemSerializer( ) ) - build_order_detail = build.serializers.BuildSerializer( - source='build_order', read_only=True, allow_null=True, many=False + build_order_detail = enable_filter( + build.serializers.BuildSerializer( + 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( @@ -1098,9 +1046,6 @@ class SalesOrderSerializer( """ queryset = AbstractOrderSerializer.annotate_queryset(queryset) - # Annotate parametric data - queryset = order.models.SalesOrder.annotate_parameters(queryset) - queryset = queryset.annotate( completed_lines=SubqueryCount('lines', filter=Q(quantity__lte=F('shipped'))) ) @@ -1128,7 +1073,8 @@ class SalesOrderSerializer( customer_detail = enable_filter( CompanyBriefSerializer( source='customer', many=False, read_only=True, allow_null=True - ) + ), + prefetch_fields=['customer'], ) shipments_count = serializers.IntegerField( @@ -1277,15 +1223,26 @@ class SalesOrderLineItemSerializer( order_detail = enable_filter( SalesOrderSerializer( 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( - 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( CompanyBriefSerializer( source='order.customer', many=False, read_only=True, allow_null=True - ) + ), + prefetch_fields=['order__customer'], ) # Annotated fields @@ -1950,9 +1907,6 @@ class ReturnOrderSerializer( """Custom annotation for the serializer queryset.""" queryset = AbstractOrderSerializer.annotate_queryset(queryset) - # Annotate parametric data - queryset = order.models.ReturnOrder.annotate_parameters(queryset) - queryset = queryset.annotate( completed_lines=SubqueryCount( 'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value) @@ -1974,7 +1928,8 @@ class ReturnOrderSerializer( customer_detail = enable_filter( CompanyBriefSerializer( source='customer', many=False, read_only=True, allow_null=True - ) + ), + prefetch_fields=['customer'], ) @@ -2134,7 +2089,14 @@ class ReturnOrderLineItemSerializer( order_detail = enable_filter( ReturnOrderSerializer( 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( @@ -2144,7 +2106,8 @@ class ReturnOrderLineItemSerializer( item_detail = enable_filter( stock.serializers.StockItemSerializer( source='item', many=False, read_only=True, allow_null=True - ) + ), + prefetch_fields=['item__supplier_part'], ) part_detail = enable_filter( diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 57fcc7cb85..aa18739cbc 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1009,7 +1009,7 @@ class PartMixin(SerializerContextMixin): """Mixin class for Part API endpoints.""" serializer_class = part_serializers.PartSerializer - queryset = Part.objects.all() + queryset = Part.objects.all().select_related('pricing_data') starred_parts = None is_create = False @@ -1020,9 +1020,6 @@ class PartMixin(SerializerContextMixin): queryset = part_serializers.PartSerializer.annotate_queryset(queryset) - if str2bool(self.request.query_params.get('price_breaks', True)): - queryset = queryset.prefetch_related('salepricebreaks') - return queryset def get_serializer(self, *args, **kwargs): diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 8a9d1e1acb..0f5ad566ae 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -9,7 +9,7 @@ from django.core.files.base import ContentFile from django.core.validators import MinValueValidator from django.db import models, transaction 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.utils.translation import gettext_lazy as _ @@ -22,6 +22,7 @@ from sql_util.utils import SubqueryCount from taggit.serializers import TagListSerializerField import common.currency +import common.filters import common.serializers import company.models import InvenTree.helpers @@ -619,7 +620,6 @@ class PartSerializer( 'required_for_build_orders', 'required_for_sales_orders', 'stock_item_count', - 'suppliers', 'total_in_stock', 'external_stock', 'unallocated_stock', @@ -680,10 +680,6 @@ class PartSerializer( 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 queryset = queryset.annotate(revision_count=SubqueryCount('revisions')) @@ -708,15 +704,6 @@ class PartSerializer( 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( ordering=part_filters.annotate_on_order_quantity(), in_stock=part_filters.annotate_total_stock(), @@ -775,7 +762,8 @@ class PartSerializer( category_detail = enable_filter( CategorySerializer( source='category', many=False, read_only=True, allow_null=True - ) + ), + prefetch_fields=['category'], ) category_path = enable_filter( @@ -786,6 +774,7 @@ class PartSerializer( allow_null=True, ), filter_name='path_detail', + prefetch_fields=['category'], ) default_location_detail = enable_filter( @@ -793,6 +782,7 @@ class PartSerializer( source='default_location', many=False, read_only=True, allow_null=True ), filter_name='location_detail', + prefetch_fields=['default_location'], ) category_name = serializers.CharField( @@ -860,10 +850,6 @@ class PartSerializer( read_only=True, allow_null=True, label=_('Revisions') ) - suppliers = serializers.IntegerField( - read_only=True, allow_null=True, label=_('Suppliers') - ) - total_in_stock = serializers.FloatField( read_only=True, allow_null=True, label=_('Total Stock') ) @@ -922,13 +908,7 @@ class PartSerializer( filter_name='pricing', ) - parameters = enable_filter( - common.serializers.ParameterSerializer( - many=True, read_only=True, allow_null=True - ), - False, - filter_name='parameters', - ) + parameters = common.filters.enable_parameters_filter() price_breaks = enable_filter( PartSalePriceSerializer( @@ -936,6 +916,7 @@ class PartSerializer( ), False, filter_name='price_breaks', + prefetch_fields=['salepricebreaks'], ) # Extra fields used only for creation of a new Part instance @@ -963,6 +944,7 @@ class PartSerializer( copy_category_parameters = serializers.BooleanField( default=True, required=False, + write_only=True, label=_('Copy Category Parameters'), help_text=_('Copy parameter templates from selected part category'), ) diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 490ee2dc87..b6541db622 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -475,9 +475,7 @@ class StockItemSerializer( """Add some extra annotations to the queryset, performing database queries as efficiently as possible.""" queryset = queryset.prefetch_related( 'location', - 'allocations', 'sales_order', - 'sales_order_allocations', 'purchase_order', Prefetch( 'part', @@ -489,25 +487,15 @@ class StockItemSerializer( ), 'parent', 'part__category', - 'part__supplier_parts', - 'part__supplier_parts__purchase_order_line_items', 'part__pricing_data', - 'part__tags', 'supplier_part', - 'supplier_part__part', - 'supplier_part__supplier', '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', 'belongs_to', 'sales_order', 'consumed_by', 'tags', - ) + ).select_related('part') # Annotate the queryset with the total allocated to sales orders queryset = queryset.annotate( @@ -586,7 +574,14 @@ class StockItemSerializer( read_only=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( @@ -604,13 +599,20 @@ class StockItemSerializer( read_only=True, allow_null=True, ), - True, + False, + prefetch_fields=['location'], ) tests = enable_filter( StockItemTestResultSerializer( source='test_results', many=True, read_only=True, allow_null=True - ) + ), + False, + prefetch_fields=[ + 'test_results', + 'test_results__user', + 'test_results__template', + ], ) quantity = InvenTreeDecimalField() diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index f3973676ef..49936d9d6c 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -866,7 +866,9 @@ class StockItemListTest(StockAPITestCase): 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( data_file, required_cols=required_headers, @@ -875,9 +877,10 @@ class StockItemListTest(StockAPITestCase): ) # Now, add a filter to the results - with self.export_data( - self.list_url, {'location': 1, 'cascade': True} - ) as data_file: + filters['location'] = 1 + filters['cascade'] = True + + with self.export_data(self.list_url, filters) as data_file: data = self.process_csv(data_file, required_rows=9) for row in data: diff --git a/src/backend/InvenTree/users/permissions.py b/src/backend/InvenTree/users/permissions.py index c4dd898ed6..67b735ae4b 100644 --- a/src/backend/InvenTree/users/permissions.py +++ b/src/backend/InvenTree/users/permissions.py @@ -1,7 +1,10 @@ """Helper functions for user permission checks.""" +from typing import Optional + from django.contrib.auth.models import User from django.db import models +from django.db.models.query import Prefetch, QuerySet import InvenTree.cache 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 +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( - 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: """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') permission: The permission to check (e.g. 'view' / 'delete') allow_inactive: If False, disallow inactive users from having permissions + groups: Optional cached queryset of groups to check (defaults to user's groups) Returns: bool: True if the user has the specified role:permission combination @@ -90,8 +112,10 @@ def check_user_role( # Default for no match result = False - for group in user.groups.all(): - for rule in group.rule_sets.all(): + groups = groups or prefetch_rule_sets(user) + + for group in groups: + for rule in group.prefetched_rule_sets: if rule.name == role: # Check if the rule has the specified permission # e.g. "view" role maps to "can_view" attribute diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 178d8c483c..28b67204f9 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -19,7 +19,7 @@ from InvenTree.serializers import ( ) 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 @@ -83,13 +83,16 @@ class RoleSerializer(InvenTreeModelSerializer): """Roles associated with the user.""" roles = {} + # Cache the 'groups' queryset for the user + groups = prefetch_rule_sets(user) + for ruleset in RULESET_CHOICES: role, _text = 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) if len(permissions) > 0: diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 976f977c62..a247b69018 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -972,8 +972,10 @@ export default function BuildLineTable({ ...params, build: build.pk, assembly_detail: false, + bom_item_detail: true, category_detail: true, - part_detail: true + part_detail: true, + allocations: true }, tableActions: tableActions, tableFilters: tableFilters, diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 319db2476f..35f86c6993 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -206,7 +206,9 @@ export default function BuildOutputTable({ .get(apiUrl(ApiEndpoints.build_line_list), { params: { build: buildId, - tracked: true + tracked: true, + bom_item_detail: true, + allocations: true } }) .then((response) => response.data); diff --git a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx index 1fbda1c775..ee4b58f650 100644 --- a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx +++ b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx @@ -165,7 +165,8 @@ export default function PartBuildAllocationsTable({ project_code_detail: true, assembly_detail: true, build_detail: true, - order_outstanding: true + order_outstanding: true, + allocations: true }, enableColumnSwitching: true, enableSearch: false, diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx index 7fc536e2b0..293ab271df 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderLineItemTable.tsx @@ -439,7 +439,8 @@ export function PurchaseOrderLineItemTable({ params: { ...params, order: orderId, - part_detail: true + part_detail: true, + destination_detail: true }, rowActions: rowActions, tableActions: tableActions, diff --git a/src/frontend/tests/pages/pui_purchase_order.spec.ts b/src/frontend/tests/pages/pui_purchase_order.spec.ts index 2ce5412e13..a31118799a 100644 --- a/src/frontend/tests/pages/pui_purchase_order.spec.ts +++ b/src/frontend/tests/pages/pui_purchase_order.spec.ts @@ -419,7 +419,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => { .getByRole('cell', { name: /Choose Location/ }) .getByText('Room 101') .waitFor(); - await page.getByText('Mechanical Lab').waitFor(); + + await page.getByText('Mechanical Lab').first().waitFor(); await page.getByRole('button', { name: 'Cancel' }).click();