2
0
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:
Oliver
2025-12-16 14:46:17 +11:00
committed by GitHub
parent 1e120c3589
commit ba7b776257
25 changed files with 508 additions and 420 deletions

View File

@@ -1,11 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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',
],
)

View File

@@ -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',

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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):

View File

@@ -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'),
)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,

View File

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

View File

@@ -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();