mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-16 09:18:10 +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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -439,7 +439,8 @@ export function PurchaseOrderLineItemTable({
|
||||
params: {
|
||||
...params,
|
||||
order: orderId,
|
||||
part_detail: true
|
||||
part_detail: true,
|
||||
destination_detail: true
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user