2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-12 10:12:58 +00:00

[WIP] Build line filter fix (#8343)

* Remove 'allocations' from BuildLineSerializer

- Expensive to have a "many" serializer automatically used
- Adjust existing tables accordingly
- Fetch on demand

* WIP: Add some unit tests

* Adjust BuildLine queryset annotation

- Multi-level annotation proves to be very expensive
- Reduce complexity, save a bunch of time on queries
- Remove 'total_allocated_stock' field
- Adjust API query filter

* Optimize query by deferring certain fields

* Further query refinements

* Bump API version
This commit is contained in:
Oliver 2024-10-25 15:54:20 +11:00 committed by GitHub
parent 075b62757a
commit 6be6c4b42f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 120 additions and 45 deletions

View File

@ -1,13 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 271 INVENTREE_API_VERSION = 272
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v272 - 2024-10-25 : https://github.com/inventree/InvenTree/pull/8343
- Adjustments to BuildLine API serializers
v271 - 2024-10-22 : https://github.com/inventree/InvenTree/pull/8331 v271 - 2024-10-22 : https://github.com/inventree/InvenTree/pull/8331
- Fixes for SalesOrderLineItem endpoints - Fixes for SalesOrderLineItem endpoints

View File

@ -370,10 +370,10 @@ class BuildLineFilter(rest_filters.FilterSet):
To determine this, we need to know: To determine this, we need to know:
- The quantity required for each BuildLine - The quantity required for each BuildLine
- The quantity available for each BuildLine - The quantity available for each BuildLine (including variants and substitutes)
- The quantity allocated for each BuildLine - The quantity allocated for each BuildLine
""" """
flt = Q(quantity__lte=F('total_available_stock') + F('allocated')) flt = Q(quantity__lte=F('allocated') + F('available_stock') + F('available_substitute_stock') + F('available_variant_stock'))
if str2bool(value): if str2bool(value):
return queryset.filter(flt) return queryset.filter(flt)
@ -399,10 +399,13 @@ class BuildLineEndpoint:
def get_queryset(self): def get_queryset(self):
"""Override queryset to select-related and annotate""" """Override queryset to select-related and annotate"""
queryset = super().get_queryset() queryset = super().get_queryset()
source_build = self.get_source_build()
queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build)
return queryset if not hasattr(self, 'source_build'):
self.source_build = self.get_source_build()
source_build = self.source_build
return build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build)
class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI): class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
@ -446,15 +449,17 @@ class BuildLineList(BuildLineEndpoint, DataExportViewMixin, ListCreateAPI):
def get_source_build(self) -> Build | None: def get_source_build(self) -> Build | None:
"""Return the target build for the BuildLine queryset.""" """Return the target build for the BuildLine queryset."""
source_build = None
try: try:
build_id = self.request.query_params.get('build', None) build_id = self.request.query_params.get('build', None)
if build_id: if build_id:
build = Build.objects.get(pk=build_id) source_build = Build.objects.filter(pk=build_id).first()
return build
except (Build.DoesNotExist, AttributeError, ValueError): except (Build.DoesNotExist, AttributeError, ValueError):
pass pass
return None return source_build
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI): class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object.""" """API endpoint for detail view of a BuildLine object."""

View File

@ -1279,7 +1279,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'bom_item_detail', 'bom_item_detail',
'part_detail', 'part_detail',
'quantity', 'quantity',
'allocations',
# Build detail fields
'build_reference',
# BOM item detail fields # BOM item detail fields
'reference', 'reference',
@ -1303,7 +1305,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'available_stock', 'available_stock',
'available_substitute_stock', 'available_substitute_stock',
'available_variant_stock', 'available_variant_stock',
'total_available_stock',
'external_stock', 'external_stock',
# Extra fields only for data export # Extra fields only for data export
@ -1317,6 +1318,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'allocations', 'allocations',
] ]
# Build info fields
build_reference = serializers.CharField(source='build.reference', label=_('Build Reference'), read_only=True)
# Part info fields # Part info fields
part = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part', label=_('Part'), many=False, read_only=True) part = serializers.PrimaryKeyRelatedField(source='bom_item.sub_part', label=_('Part'), many=False, read_only=True)
part_name = serializers.CharField(source='bom_item.sub_part.name', label=_('Part Name'), read_only=True) part_name = serializers.CharField(source='bom_item.sub_part.name', label=_('Part Name'), read_only=True)
@ -1340,9 +1344,17 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True) bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
# Foreign key fields # Foreign key fields
bom_item_detail = part_serializers.BomItemSerializer(source='bom_item', many=False, read_only=True, pricing=False) bom_item_detail = part_serializers.BomItemSerializer(
source='bom_item',
many=False,
read_only=True,
pricing=False,
substitutes=False,
sub_part_detail=False,
part_detail=False
)
part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False) part_detail = part_serializers.PartBriefSerializer(source='bom_item.sub_part', many=False, read_only=True, pricing=False)
allocations = BuildItemSerializer(many=True, read_only=True)
# Annotated (calculated) fields # Annotated (calculated) fields
allocated = serializers.FloatField( allocated = serializers.FloatField(
@ -1360,15 +1372,10 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
read_only=True read_only=True
) )
available_stock = serializers.FloatField( external_stock = serializers.FloatField(read_only=True, label=_('External Stock'))
label=_('Available Stock'), available_stock = serializers.FloatField(read_only=True, label=_('Available Stock'))
read_only=True
)
available_substitute_stock = serializers.FloatField(read_only=True, label=_('Available Substitute Stock')) available_substitute_stock = serializers.FloatField(read_only=True, label=_('Available Substitute Stock'))
available_variant_stock = serializers.FloatField(read_only=True, label=_('Available Variant Stock')) available_variant_stock = serializers.FloatField(read_only=True, label=_('Available Variant Stock'))
total_available_stock = serializers.FloatField(read_only=True, label=_('Total Available Stock'))
external_stock = serializers.FloatField(read_only=True, label=_('External Stock'))
@staticmethod @staticmethod
def annotate_queryset(queryset, build=None): def annotate_queryset(queryset, build=None):
@ -1390,14 +1397,13 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'build', 'build',
'bom_item', 'bom_item',
'bom_item__part', 'bom_item__part',
'bom_item__part__pricing_data',
'bom_item__sub_part', 'bom_item__sub_part',
'bom_item__sub_part__pricing_data',
) )
# Pre-fetch related fields # Pre-fetch related fields
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'bom_item__sub_part__tags', 'allocations',
'bom_item__sub_part__stock_items', 'bom_item__sub_part__stock_items',
'bom_item__sub_part__stock_items__allocations', 'bom_item__sub_part__stock_items__allocations',
'bom_item__sub_part__stock_items__sales_order_allocations', 'bom_item__sub_part__stock_items__sales_order_allocations',
@ -1406,21 +1412,58 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
'bom_item__substitutes__part__stock_items', 'bom_item__substitutes__part__stock_items',
'bom_item__substitutes__part__stock_items__allocations', 'bom_item__substitutes__part__stock_items__allocations',
'bom_item__substitutes__part__stock_items__sales_order_allocations', 'bom_item__substitutes__part__stock_items__sales_order_allocations',
)
'allocations', # Defer expensive fields which we do not need for this serializer
'allocations__stock_item',
'allocations__stock_item__part', queryset = queryset.defer(
'allocations__stock_item__location', 'build__lft',
'allocations__stock_item__location__tags', 'build__rght',
'allocations__stock_item__supplier_part', 'build__level',
'allocations__stock_item__supplier_part__part', 'build__tree_id',
'allocations__stock_item__supplier_part__supplier', 'build__destination',
'allocations__stock_item__supplier_part__manufacturer_part', 'build__take_from',
'allocations__stock_item__supplier_part__manufacturer_part__manufacturer', 'build__completed_by',
'build__issued_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',
) )
# Annotate the "allocated" quantity # Annotate the "allocated" quantity
# Difficulty: Easy
queryset = queryset.annotate( queryset = queryset.annotate(
allocated=Coalesce( allocated=Coalesce(
Sum('allocations__quantity'), 0, Sum('allocations__quantity'), 0,
@ -1448,7 +1491,6 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
) )
# Annotate the "on_order" quantity # Annotate the "on_order" quantity
# Difficulty: Medium
queryset = queryset.annotate( queryset = queryset.annotate(
on_order=part.filters.annotate_on_order_quantity(reference=ref), on_order=part.filters.annotate_on_order_quantity(reference=ref),
) )
@ -1511,12 +1553,4 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali
) )
) )
# Annotate with the 'total available stock'
queryset = queryset.annotate(
total_available_stock=ExpressionWrapper(
F('available_stock') + F('available_substitute_stock') + F('available_variant_stock'),
output_field=FloatField(),
)
)
return queryset return queryset

View File

@ -7,7 +7,7 @@ from django.urls import reverse
from rest_framework import status from rest_framework import status
from part.models import Part, BomItem from part.models import Part, BomItem
from build.models import Build, BuildItem from build.models import Build, BuildItem, BuildLine
from stock.models import StockItem from stock.models import StockItem
from build.status_codes import BuildStatus from build.status_codes import BuildStatus
@ -1471,3 +1471,29 @@ class BuildOutputScrapTest(BuildAPITest):
output.refresh_from_db() output.refresh_from_db()
self.assertEqual(output.status, StockStatus.REJECTED) self.assertEqual(output.status, StockStatus.REJECTED)
self.assertFalse(output.is_building) self.assertFalse(output.is_building)
class BuildLineTests(BuildAPITest):
"""Unit tests for the BuildLine API endpoints."""
def test_filter_available(self):
"""Filter BuildLine objects by 'available' status."""
url = reverse('api-build-line-list')
# First *all* BuildLine objects
response = self.get(url)
self.assertEqual(len(response.data), BuildLine.objects.count())
# Filter by 'available' status
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
response = self.get(url, data={'available': True}, max_query_time=15)
n_t = len(response.data)
self.assertGreater(n_t, 0)
# Note: The max_query_time is bumped up here, as postgresql backend has some strange issues (only during testing)
response = self.get(url, data={'available': False}, max_query_time=15)
n_f = len(response.data)
self.assertGreater(n_f, 0)
self.assertEqual(n_t + n_f, BuildLine.objects.count())

View File

@ -1599,6 +1599,7 @@ class BomItemSerializer(
part_detail = kwargs.pop('part_detail', False) part_detail = kwargs.pop('part_detail', False)
sub_part_detail = kwargs.pop('sub_part_detail', True) sub_part_detail = kwargs.pop('sub_part_detail', True)
pricing = kwargs.pop('pricing', True) pricing = kwargs.pop('pricing', True)
substitutes = kwargs.pop('substitutes', True)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -1608,6 +1609,9 @@ class BomItemSerializer(
if not sub_part_detail: if not sub_part_detail:
self.fields.pop('sub_part_detail', None) self.fields.pop('sub_part_detail', None)
if not substitutes:
self.fields.pop('substitutes', None)
if not pricing: if not pricing:
self.fields.pop('pricing_min', None) self.fields.pop('pricing_min', None)
self.fields.pop('pricing_max', None) self.fields.pop('pricing_max', None)

View File

@ -2502,7 +2502,10 @@ function renderBuildLineAllocationTable(element, build_line, options={}) {
// Load the allocation items into the table // Load the allocation items into the table
sub_table.bootstrapTable({ sub_table.bootstrapTable({
data: build_line.allocations, url: '{% url "api-build-item-list" %}',
queryParams: {
build_line: build_line.pk,
},
showHeader: false, showHeader: false,
columns: [ columns: [
{ {