From df218a519332b706e3eb8c7e4fa79a125cee643d Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 28 Apr 2020 22:57:18 +1000 Subject: [PATCH 01/14] Fix rendering of "used in" page --- InvenTree/part/templates/part/used_in.html | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/templates/part/used_in.html b/InvenTree/part/templates/part/used_in.html index 1d3589a0d3..ce0491a165 100644 --- a/InvenTree/part/templates/part/used_in.html +++ b/InvenTree/part/templates/part/used_in.html @@ -1,10 +1,10 @@ {% extends "part/part_base.html" %} - +{% load i18n %} {% block details %} {% include 'part/tabs.html' with tab='used' %} -

Assemblies

+

{% trans "Assemblies" %}


@@ -35,10 +35,11 @@ title: 'Part', sortable: true, formatter: function(value, row, index, field) { - var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value.full_name, value.url + 'bom/'); + var link = `/part/${value.pk}/bom/`; + var html = imageHoverIcon(row.part_detail.thumbnail) + renderLink(value.full_name, link); if (!row.part_detail.active) { - html += "INACTIVE"; + html += "{% trans "INACTIVE" %}"; } return html; From 087001f29d63feeecdc0c0fc849cc79dcaa921e7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 28 Apr 2020 23:17:15 +1000 Subject: [PATCH 02/14] Fix BOM table rendering - Price was not being sent in the PartBriefSerializer! --- .../InvenTree/static/script/inventree/bom.js | 24 +++++++------------ InvenTree/part/api.py | 20 ++++++++++------ InvenTree/part/models.py | 11 +++++++++ InvenTree/part/serializers.py | 6 +++++ InvenTree/stock/models.py | 1 - 5 files changed, 39 insertions(+), 23 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/bom.js b/InvenTree/InvenTree/static/script/inventree/bom.js index 55337e3fa8..f93d882630 100644 --- a/InvenTree/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/InvenTree/static/script/inventree/bom.js @@ -185,26 +185,20 @@ function loadBomTable(table, options) { if (!options.editable) { cols.push( { - field: 'sub_part_detail.total_stock', + field: 'sub_part_detail.stock', title: 'Available', searchable: false, sortable: true, formatter: function(value, row, index, field) { - var text = ""; - - if (row.quantity < row.sub_part_detail.total_stock) - { - text = "" + value + ""; + + var url = `/part/${row.sub_part_detail.pk}/stock/`; + var text = value; + + if (value == null || value <= 0) { + text = `No Stock`; } - else - { - if (!value) { - value = 'No Stock'; - } - text = "" + value + ""; - } - - return renderLink(text, row.sub_part_detail.url + "stock/"); + + return renderLink(text, url); } }); diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 51a4a35938..c265bf77fd 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -468,15 +468,15 @@ class BomList(generics.ListCreateAPIView): # Do we wish to include extra detail? try: - part_detail = str2bool(self.request.GET.get('part_detail', None)) - sub_part_detail = str2bool(self.request.GET.get('sub_part_detail', None)) + kwargs['part_detail'] = str2bool(self.request.GET.get('part_detail', None)) except AttributeError: - part_detail = None - sub_part_detail = None - - kwargs['part_detail'] = part_detail - kwargs['sub_part_detail'] = sub_part_detail + pass + try: + kwargs['sub_part_detail'] = str2bool(self.request.GET.get('sub_part_detail', None)) + except AttributeError: + pass + # Ensure the request context is passed through! kwargs['context'] = self.get_serializer_context() @@ -486,6 +486,12 @@ class BomList(generics.ListCreateAPIView): queryset = BomItem.objects.all() queryset = self.get_serializer_class().setup_eager_loading(queryset) + return queryset + + def filter_queryset(self, queryset): + + query = super().filter_queryset(queryset) + # Filter by part? part = self.request.query_params.get('part', None) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 021695f9bf..6f6b0d7ddf 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1242,6 +1242,17 @@ class BomItem(models.Model): child=self.sub_part.full_name, n=helpers.decimal2string(self.quantity)) + def available_stock(self): + """ + Return the available stock items for the referenced sub_part + """ + + query = self.sub_part.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate( + available=Coalesce(Sum('quantity'), 0) + ) + + return query['available'] + def get_overage_quantity(self, quantity): """ Calculate overage quantity """ diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 8cb3584664..b56bbc588f 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -54,6 +54,8 @@ class PartBriefSerializer(InvenTreeModelSerializer): thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) + stock = serializers.FloatField(source='total_stock') + class Meta: model = Part fields = [ @@ -65,6 +67,7 @@ class PartBriefSerializer(InvenTreeModelSerializer): 'assembly', 'purchaseable', 'salable', + 'stock', 'virtual', ] @@ -236,6 +239,8 @@ class BomItemSerializer(InvenTreeModelSerializer): price_range = serializers.CharField(read_only=True) quantity = serializers.FloatField() + + available = serializers.FloatField(source='available_stock') part_detail = PartBriefSerializer(source='part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) @@ -277,6 +282,7 @@ class BomItemSerializer(InvenTreeModelSerializer): 'sub_part', 'sub_part_detail', 'quantity', + 'available', 'reference', 'price_range', 'overage', diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 36e8064138..f514eea3da 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -137,7 +137,6 @@ class StockItem(MPTTModel): sales_order=None, build_order=None, belongs_to=None, - status__in=StockStatus.AVAILABLE_CODES ) def save(self, *args, **kwargs): From 3b42b5e27e45c82a46804c4a5d9e1af51bf1b984 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 28 Apr 2020 23:17:59 +1000 Subject: [PATCH 03/14] Typo fix for part api --- InvenTree/part/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index c265bf77fd..310cf3b94f 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -490,7 +490,7 @@ class BomList(generics.ListCreateAPIView): def filter_queryset(self, queryset): - query = super().filter_queryset(queryset) + queryset = super().filter_queryset(queryset) # Filter by part? part = self.request.query_params.get('part', None) From 753fab2498e46358e82887fa31269f2ba38c9cb1 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 28 Apr 2020 23:23:59 +1000 Subject: [PATCH 04/14] Fix url links in BOM table --- InvenTree/InvenTree/static/script/inventree/bom.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/bom.js b/InvenTree/InvenTree/static/script/inventree/bom.js index f93d882630..e4b90a9f26 100644 --- a/InvenTree/InvenTree/static/script/inventree/bom.js +++ b/InvenTree/InvenTree/static/script/inventree/bom.js @@ -133,11 +133,14 @@ function loadBomTable(table, options) { title: 'Part', sortable: true, formatter: function(value, row, index, field) { - var html = imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, row.sub_part_detail.url); + var url = `/part/${row.sub_part}/`; + var html = imageHoverIcon(row.sub_part_detail.thumbnail) + renderLink(row.sub_part_detail.full_name, url); // Display an extra icon if this part is an assembly if (row.sub_part_detail.assembly) { - html += ""; + var text = ``; + + html += renderLink(text, `/part/${row.sub_part}/bom/`); } return html; From a345e0e8da99a988e4fa6f45e30eb67e958196fd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 28 Apr 2020 23:26:27 +1000 Subject: [PATCH 05/14] Removed unused serializer field --- InvenTree/part/serializers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index b56bbc588f..e713de455f 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -239,8 +239,6 @@ class BomItemSerializer(InvenTreeModelSerializer): price_range = serializers.CharField(read_only=True) quantity = serializers.FloatField() - - available = serializers.FloatField(source='available_stock') part_detail = PartBriefSerializer(source='part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) @@ -282,7 +280,6 @@ class BomItemSerializer(InvenTreeModelSerializer): 'sub_part', 'sub_part_detail', 'quantity', - 'available', 'reference', 'price_range', 'overage', From af00d885558ffbf8767e65c49b9b3a4758dcdd74 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 1 May 2020 15:57:10 +1000 Subject: [PATCH 06/14] Bug fix for custom migration Ref: https://stackoverflow.com/questions/13410982/attributeerror-long-object-has-no-attribute-fetchall#13411358 --- InvenTree/company/migrations/0019_auto_20200413_0642.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/company/migrations/0019_auto_20200413_0642.py b/InvenTree/company/migrations/0019_auto_20200413_0642.py index f683ee783a..c81dfd795a 100644 --- a/InvenTree/company/migrations/0019_auto_20200413_0642.py +++ b/InvenTree/company/migrations/0019_auto_20200413_0642.py @@ -95,7 +95,7 @@ def associate_manufacturers(apps, schema_editor): cursor = connection.cursor() response = cursor.execute(query) - row = response.fetchone() + row = cursor.fetchone() if len(row) > 0: return row[0] From 7fff0a74276ad2b71288f4edb919407f9b7bb982 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 1 May 2020 17:02:11 +1000 Subject: [PATCH 07/14] Catch an error if git cannot be found --- InvenTree/InvenTree/version.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 3ac225fd6e..e76123c6d8 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -27,12 +27,17 @@ def inventreeDjangoVersion(): def inventreeCommitHash(): """ Returns the git commit hash for the running codebase """ - return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip() + try: + return str(subprocess.check_output('git rev-parse --short HEAD'.split()), 'utf-8').strip() + except FileNotFoundError: + return None def inventreeCommitDate(): """ Returns the git commit date for the running codebase """ - d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip() - - return d.split(' ')[0] + try: + d = str(subprocess.check_output('git show -s --format=%ci'.split()), 'utf-8').strip() + return d.split(' ')[0] + except FileNotFoundError: + return None From 4a60da67fd92f0027ad5604a98582064a3985661 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 May 2020 09:49:05 +1000 Subject: [PATCH 08/14] Significant increase in query speed for Part list - Custom list method - Cache PartCategory objects in memory --- InvenTree/part/api.py | 68 +++++++++++++++++++++++++++++++---- InvenTree/part/serializers.py | 2 ++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 310cf3b94f..9eb184b1c2 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -110,7 +110,7 @@ class PartThumbs(generics.ListAPIView): serializer_class = part_serializers.PartThumbSerializer - def list(self, reguest, *args, **kwargs): + def list(self, request, *args, **kwargs): """ Serialize the available Part images. - Images may be used for multiple parts! @@ -142,6 +142,7 @@ class PartDetail(generics.RetrieveUpdateAPIView): queryset = part_serializers.PartSerializer.prefetch_queryset(queryset) queryset = part_serializers.PartSerializer.annotate_queryset(queryset) + return queryset permission_classes = [ @@ -151,15 +152,13 @@ class PartDetail(generics.RetrieveUpdateAPIView): def get_serializer(self, *args, **kwargs): try: - cat_detail = str2bool(self.request.query_params.get('category_detail', False)) + kwargs['category_detail'] = str2bool(self.request.query_params.get('category_detail', False)) except AttributeError: - cat_detail = None + pass # Ensure the request context is passed through kwargs['context'] = self.get_serializer_context() - kwargs['category_detail'] = cat_detail - # Pass a list of "starred" parts fo the current user to the serializer # We do this to reduce the number of database queries required! if self.starred_parts is None and self.request is not None: @@ -206,8 +205,6 @@ class PartList(generics.ListCreateAPIView): # Ensure the request context is passed through kwargs['context'] = self.get_serializer_context() - kwargs['category_detail'] = cat_detail - # Pass a list of "starred" parts fo the current user to the serializer # We do this to reduce the number of database queries required! if self.starred_parts is None and self.request is not None: @@ -217,6 +214,63 @@ class PartList(generics.ListCreateAPIView): return self.serializer_class(*args, **kwargs) + def list(self, request, *args, **kwargs): + """ + Overide the 'list' method, as the PartCategory objects are + very expensive to serialize! + + So we will serialize them first, and keep them in memory, + so that they do not have to be serialized multiple times... + """ + + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + + data = serializer.data + + # Do we wish to include PartCategory detail? + if str2bool(request.query_params.get('category_detail', False)): + + # Work out which part categorie we need to query + category_ids = set() + + for part in data: + cat_id = part['category'] + + if cat_id is not None: + category_ids.add(part['category']) + + # Fetch only the required PartCategory objects from the database + categories = PartCategory.objects.filter(pk__in=category_ids).prefetch_related( + 'parts', + 'parent', + 'children', + ) + + category_map = {} + + # Serialize each PartCategory object + for category in categories: + category_map[category.pk] = part_serializers.CategorySerializer(category).data + + for part in data: + cat_id = part['category'] + + if cat_id is not None and cat_id in category_map.keys(): + detail = category_map[part['category']] + else: + detail = None + + part['category_detail'] = detail + + return Response(data) + def create(self, request, *args, **kwargs): """ Override the default 'create' behaviour: We wish to save the user who created this part! diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index e713de455f..86a3189c43 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -101,6 +101,8 @@ class PartSerializer(InvenTreeModelSerializer): return queryset.prefetch_related( 'category', + 'category__parts', + 'category__parent', 'stock_items', 'bom_items', 'builds', From a537b6df6e35d5b38ff5114aabf8af6e16c1865a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 May 2020 09:50:18 +1000 Subject: [PATCH 09/14] PEP fixes --- InvenTree/part/api.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 9eb184b1c2..bcbd5bf41a 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -197,11 +197,6 @@ class PartList(generics.ListCreateAPIView): def get_serializer(self, *args, **kwargs): - try: - cat_detail = str2bool(self.request.query_params.get('category_detail', False)) - except AttributeError: - cat_detail = None - # Ensure the request context is passed through kwargs['context'] = self.get_serializer_context() From 44319d24e4e0b22490e2ae24e214f5173d5fab0a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 May 2020 10:05:35 +1000 Subject: [PATCH 10/14] Custom list serializer for 'location_detail' --- InvenTree/part/api.py | 4 +-- InvenTree/stock/api.py | 63 ++++++++++++++++++++++++++++++---- InvenTree/stock/serializers.py | 3 -- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index bcbd5bf41a..f776c208b5 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -239,7 +239,7 @@ class PartList(generics.ListCreateAPIView): cat_id = part['category'] if cat_id is not None: - category_ids.add(part['category']) + category_ids.add(cat_id) # Fetch only the required PartCategory objects from the database categories = PartCategory.objects.filter(pk__in=category_ids).prefetch_related( @@ -258,7 +258,7 @@ class PartList(generics.ListCreateAPIView): cat_id = part['category'] if cat_id is not None and cat_id in category_map.keys(): - detail = category_map[part['category']] + detail = category_map[cat_id] else: detail = None diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index c31c1b8993..d1f6c2d944 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -15,7 +15,7 @@ from .models import StockItemTracking from part.models import Part, PartCategory from .serializers import StockItemSerializer -from .serializers import LocationSerializer +from .serializers import LocationSerializer, LocationBriefSerializer from .serializers import StockTrackingSerializer from InvenTree.views import TreeSerializer @@ -332,11 +332,6 @@ class StockList(generics.ListCreateAPIView): except AttributeError: pass - try: - kwargs['location_detail'] = str2bool(self.request.query_params.get('location_detail', None)) - except AttributeError: - pass - try: kwargs['supplier_part_detail'] = str2bool(self.request.query_params.get('supplier_part_detail', None)) except AttributeError: @@ -350,6 +345,62 @@ class StockList(generics.ListCreateAPIView): # TODO - Override the 'create' method for this view, # to allow the user to be recorded when a new StockItem object is created + def list(self, request, *args, **kwargs): + """ + Override the 'list' method, as the StockLocation objects + are very expensive to serialize. + + So, we fetch and serialize the required StockLocation objects only as required. + """ + + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + + data = serializer.data + + # Do we wish to include StockLocation detail? + if str2bool(request.query_params.get('location_detail', False)): + + # Work out which locations we need to query + location_ids = set() + + for stock_item in data: + loc_id = stock_item['location'] + + if loc_id is not None: + location_ids.add(loc_id) + + # Fetch only the required StockLocation objects from the database + locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related( + 'parent', + 'children', + ) + + location_map = {} + + # Serialize each StockLocation object + for location in locations: + location_map[location.pk] = LocationBriefSerializer(location).data + + # Now update each StockItem with the related StockLocation data + for stock_item in data: + loc_id = stock_item['location'] + + if loc_id is not None and loc_id in location_map.keys(): + detail = location_map[loc_id] + else: + detail = None + + stock_item['location_detail'] = detail + + return Response(data) + def get_queryset(self, *args, **kwargs): queryset = super().get_queryset(*args, **kwargs) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 4e586b789e..eeab49eb4b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -17,15 +17,12 @@ class LocationBriefSerializer(InvenTreeModelSerializer): Provides a brief serializer for a StockLocation object """ - url = serializers.CharField(source='get_absolute_url', read_only=True) - class Meta: model = StockLocation fields = [ 'pk', 'name', 'pathstring', - 'url', ] From 4197e29fce1c28fe43a6020f129ca56045c79ff0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 May 2020 13:46:19 +1000 Subject: [PATCH 11/14] Major major major (improvements for StockItem list API) OK LISTEN UP - Lots of work went into making this speedier: - For related detail fields (e.g. part_detail), we pre-fetch and cache the model data - This eliminates duplicate database hits for the same model instances - Perform all field filtering manually, rather than using the DRF 'filter_fields' concept (this seems to add a lot of overhead) - Use query annotations to getch calculated fields rather than fetching one-at-a-time - And finally, if the request is AJAX then return a JsonResponse which is SO FREAKING MUCH FASTER --- InvenTree/part/api.py | 4 +- InvenTree/stock/api.py | 176 +++++++++++++++++++++------------ InvenTree/stock/serializers.py | 20 +++- 3 files changed, 134 insertions(+), 66 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index f776c208b5..0d61025a8e 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -293,10 +293,10 @@ class PartList(generics.ListCreateAPIView): def filter_queryset(self, queryset): """ - Perform custom filtering of the queryset + Perform custom filtering of the queryset. + We overide the DRF filter_fields here because """ - # Perform basic filtering queryset = super().filter_queryset(queryset) # Filter by 'starred' parts? diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index d1f6c2d944..4816966718 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -7,12 +7,17 @@ from django_filters import NumberFilter from django.conf.urls import url, include from django.urls import reverse +from django.http import JsonResponse from django.db.models import Q from .models import StockLocation, StockItem from .models import StockItemTracking from part.models import Part, PartCategory +from part.serializers import PartBriefSerializer + +from company.models import SupplierPart +from company.serializers import SupplierPartSerializer from .serializers import StockItemSerializer from .serializers import LocationSerializer, LocationBriefSerializer @@ -322,26 +327,8 @@ class StockList(generics.ListCreateAPIView): """ serializer_class = StockItemSerializer - queryset = StockItem.objects.all() - def get_serializer(self, *args, **kwargs): - - try: - kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', None)) - except AttributeError: - pass - - try: - kwargs['supplier_part_detail'] = str2bool(self.request.query_params.get('supplier_part_detail', None)) - except AttributeError: - pass - - # Ensure the request context is passed through - kwargs['context'] = self.get_serializer_context() - - return self.serializer_class(*args, **kwargs) - # TODO - Override the 'create' method for this view, # to allow the user to be recorded when a new StockItem object is created @@ -364,17 +351,59 @@ class StockList(generics.ListCreateAPIView): data = serializer.data - # Do we wish to include StockLocation detail? - if str2bool(request.query_params.get('location_detail', False)): + # Keep track of which related models we need to query + location_ids = set() + part_ids = set() + supplier_part_ids = set() - # Work out which locations we need to query - location_ids = set() + # Iterate through each StockItem and grab some data + for item in data: + loc = item['location'] + if loc: + location_ids.add(loc) + + part = item['part'] + if part: + part_ids.add(part) + + sp = item['supplier_part'] + if sp: + supplier_part_ids.add(sp) + + # Do we wish to include Part detail? + if str2bool(request.query_params.get('part_detail', False)): + + # Fetch only the required Part objects from the database + parts = Part.objects.filter(pk__in=part_ids).prefetch_related( + 'category', + ) + + part_map = {} + + for part in parts: + part_map[part.pk] = PartBriefSerializer(part).data + + # Now update each StockItem with the related Part data + for stock_item in data: + part_id = stock_item['part'] + stock_item['part_detail'] = part_map.get(part_id, None) + + # Do we wish to include SupplierPart detail? + if str2bool(request.query_params.get('supplier_part_detail', False)): + + supplier_parts = SupplierPart.objects.filter(pk__in=supplier_part_ids) + + supplier_part_map = {} + + for part in supplier_parts: + supplier_part_map[part.pk] = SupplierPartSerializer(part).data for stock_item in data: - loc_id = stock_item['location'] + part_id = stock_item['supplier_part'] + stock_item['supplier_part_detail'] = supplier_part_map.get(part_id, None) - if loc_id is not None: - location_ids.add(loc_id) + # Do we wish to include StockLocation detail? + if str2bool(request.query_params.get('location_detail', False)): # Fetch only the required StockLocation objects from the database locations = StockLocation.objects.filter(pk__in=location_ids).prefetch_related( @@ -391,27 +420,60 @@ class StockList(generics.ListCreateAPIView): # Now update each StockItem with the related StockLocation data for stock_item in data: loc_id = stock_item['location'] + stock_item['supplier_detail'] = location_map.get(loc_id, None) - if loc_id is not None and loc_id in location_map.keys(): - detail = location_map[loc_id] - else: - detail = None + """ + Determine the response type based on the request. + a) For HTTP requests (e.g. via the browseable API) return a DRF response + b) For AJAX requests, simply return a JSON rendered response. - stock_item['location_detail'] = detail + Note: b) is about 100x quicker than a), because the DRF framework adds a lot of cruft + """ - return Response(data) + if request.is_ajax(): + return JsonResponse(data, safe=False) + else: + return Response(data) def get_queryset(self, *args, **kwargs): queryset = super().get_queryset(*args, **kwargs) queryset = StockItemSerializer.prefetch_queryset(queryset) + queryset = StockItemSerializer.annotate_queryset(queryset) return queryset def filter_queryset(self, queryset): - # Start with all objects - stock_list = super().filter_queryset(queryset) + params = self.request.query_params + + # Perform basic filtering: + # Note: We do not let DRF filter here, it be slow AF + + supplier_part = params.get('supplier_part', None) + + if supplier_part: + queryset = queryset.filter(supplier_part=supplier_part) + + belongs_to = params.get('belongs_to', None) + + if belongs_to: + queryset = queryset.filter(belongs_to=belongs_to) + + build = params.get('build', None) + + if build: + queryset = queryset.filter(build=build) + + build_order = params.get('build_order', None) + + if build_order: + queryset = queryset.filter(build_order=build_order) + + sales_order = params.get('sales_order', None) + + if sales_order: + queryset = queryset.filter(sales_order=sales_order) in_stock = self.request.query_params.get('in_stock', None) @@ -420,10 +482,10 @@ class StockList(generics.ListCreateAPIView): if in_stock: # Filter out parts which are not actually "in stock" - stock_list = stock_list.filter(StockItem.IN_STOCK_FILTER) + queryset = queryset.filter(StockItem.IN_STOCK_FILTER) else: # Only show parts which are not in stock - stock_list = stock_list.exclude(StockItem.IN_STOCK_FILTER) + queryset = queryset.exclude(StockItem.IN_STOCK_FILTER) # Filter by 'allocated' patrs? allocated = self.request.query_params.get('allocated', None) @@ -433,17 +495,17 @@ class StockList(generics.ListCreateAPIView): if allocated: # Filter StockItem with either build allocations or sales order allocations - stock_list = stock_list.filter(Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)) + queryset = queryset.filter(Q(sales_order_allocations__isnull=False) | Q(allocations__isnull=False)) else: # Filter StockItem without build allocations or sales order allocations - stock_list = stock_list.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) + queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) # Do we wish to filter by "active parts" active = self.request.query_params.get('active', None) if active is not None: active = str2bool(active) - stock_list = stock_list.filter(part__active=active) + queryset = queryset.filter(part__active=active) # Does the client wish to filter by the Part ID? part_id = self.request.query_params.get('part', None) @@ -454,9 +516,9 @@ class StockList(generics.ListCreateAPIView): # If the part is a Template part, select stock items for any "variant" parts under that template if part.is_template: - stock_list = stock_list.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)]) + queryset = queryset.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)]) else: - stock_list = stock_list.filter(part=part_id) + queryset = queryset.filter(part=part_id) except (ValueError, Part.DoesNotExist): raise ValidationError({"part": "Invalid Part ID specified"}) @@ -469,7 +531,7 @@ class StockList(generics.ListCreateAPIView): ancestor = StockItem.objects.get(pk=anc_id) # Only allow items which are descendants of the specified StockItem - stock_list = stock_list.filter(id__in=[item.pk for item in ancestor.children.all()]) + queryset = queryset.filter(id__in=[item.pk for item in ancestor.children.all()]) except (ValueError, Part.DoesNotExist): raise ValidationError({"ancestor": "Invalid ancestor ID specified"}) @@ -483,15 +545,15 @@ class StockList(generics.ListCreateAPIView): # Filter by 'null' location (i.e. top-level items) if isNull(loc_id): - stock_list = stock_list.filter(location=None) + queryset = queryset.filter(location=None) else: try: # If '?cascade=true' then include items which exist in sub-locations if cascade: location = StockLocation.objects.get(pk=loc_id) - stock_list = stock_list.filter(location__in=location.getUniqueChildren()) + queryset = queryset.filter(location__in=location.getUniqueChildren()) else: - stock_list = stock_list.filter(location=loc_id) + queryset = queryset.filter(location=loc_id) except (ValueError, StockLocation.DoesNotExist): pass @@ -502,7 +564,7 @@ class StockList(generics.ListCreateAPIView): if cat_id: try: category = PartCategory.objects.get(pk=cat_id) - stock_list = stock_list.filter(part__category__in=category.getUniqueChildren()) + queryset = queryset.filter(part__category__in=category.getUniqueChildren()) except (ValueError, PartCategory.DoesNotExist): raise ValidationError({"category": "Invalid category id specified"}) @@ -511,44 +573,42 @@ class StockList(generics.ListCreateAPIView): status = self.request.query_params.get('status', None) if status: - stock_list = stock_list.filter(status=status) + queryset = queryset.filter(status=status) # Filter by supplier_part ID supplier_part_id = self.request.query_params.get('supplier_part', None) if supplier_part_id: - stock_list = stock_list.filter(supplier_part=supplier_part_id) + queryset = queryset.filter(supplier_part=supplier_part_id) # Filter by company (either manufacturer or supplier) company = self.request.query_params.get('company', None) if company is not None: - stock_list = stock_list.filter(Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer=company)) + queryset = queryset.filter(Q(supplier_part__supplier=company) | Q(supplier_part__manufacturer=company)) # Filter by supplier supplier = self.request.query_params.get('supplier', None) if supplier is not None: - stock_list = stock_list.filter(supplier_part__supplier=supplier) + queryset = queryset.filter(supplier_part__supplier=supplier) # Filter by manufacturer manufacturer = self.request.query_params.get('manufacturer', None) if manufacturer is not None: - stock_list = stock_list.filter(supplier_part__manufacturer=manufacturer) + queryset = queryset.filter(supplier_part__manufacturer=manufacturer) # Also ensure that we pre-fecth all the related items - stock_list = stock_list.prefetch_related( + queryset = queryset.prefetch_related( 'part', 'part__category', 'location' ) - stock_list = stock_list.order_by('part__name') + queryset = queryset.order_by('part__name') - return stock_list - - serializer_class = StockItemSerializer + return queryset permission_classes = [ permissions.IsAuthenticated, @@ -561,12 +621,6 @@ class StockList(generics.ListCreateAPIView): ] filter_fields = [ - 'supplier_part', - 'belongs_to', - 'build', - 'build_order', - 'sales_order', - 'build_order', ] diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index eeab49eb4b..6b34fbe6ce 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -7,6 +7,9 @@ from rest_framework import serializers from .models import StockItem, StockLocation from .models import StockItemTracking +from django.db.models import Sum, Count +from django.db.models.functions import Coalesce + from company.serializers import SupplierPartSerializer from part.serializers import PartBriefSerializer from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer @@ -62,6 +65,10 @@ class StockItemSerializer(InvenTreeModelSerializer): """ return queryset.prefetch_related( + 'belongs_to', + 'build', + 'build_order', + 'sales_order', 'supplier_part', 'supplier_part__supplier', 'supplier_part__manufacturer', @@ -79,7 +86,13 @@ class StockItemSerializer(InvenTreeModelSerializer): performing database queries as efficiently as possible. """ - # TODO - Add custom annotated fields + queryset = queryset.annotate( + allocated = Coalesce( + Sum('sales_order_allocations__quantity', distinct=True), 0) + Coalesce( + Sum('allocations__quantity', distinct=True), 0), + tracking_items = Count('tracking_info'), + ) + return queryset status_text = serializers.CharField(source='get_status_display', read_only=True) @@ -88,10 +101,10 @@ class StockItemSerializer(InvenTreeModelSerializer): location_detail = LocationBriefSerializer(source='location', many=False, read_only=True) supplier_part_detail = SupplierPartSerializer(source='supplier_part', many=False, read_only=True) - tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True) + tracking_items = serializers.IntegerField() quantity = serializers.FloatField() - allocated = serializers.FloatField(source='allocation_count', read_only=True) + allocated = serializers.FloatField() def __init__(self, *args, **kwargs): @@ -140,6 +153,7 @@ class StockItemSerializer(InvenTreeModelSerializer): They can be updated by accessing the appropriate API endpoints """ read_only_fields = [ + 'allocated', 'stocktake_date', 'stocktake_user', 'updated', From 7fca496de8aaf55fd47e4df94c76bd8dad7068c6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 May 2020 13:51:29 +1000 Subject: [PATCH 12/14] Bug fix for StockItem list API - The wrong detail data was being set --- InvenTree/part/api.py | 2 +- InvenTree/stock/api.py | 2 +- InvenTree/stock/serializers.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0d61025a8e..335853331c 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -294,7 +294,7 @@ class PartList(generics.ListCreateAPIView): def filter_queryset(self, queryset): """ Perform custom filtering of the queryset. - We overide the DRF filter_fields here because + We overide the DRF filter_fields here because """ queryset = super().filter_queryset(queryset) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 4816966718..6b7c15f980 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -420,7 +420,7 @@ class StockList(generics.ListCreateAPIView): # Now update each StockItem with the related StockLocation data for stock_item in data: loc_id = stock_item['location'] - stock_item['supplier_detail'] = location_map.get(loc_id, None) + stock_item['location_detail'] = location_map.get(loc_id, None) """ Determine the response type based on the request. diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 6b34fbe6ce..e04e2a149b 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -87,10 +87,10 @@ class StockItemSerializer(InvenTreeModelSerializer): """ queryset = queryset.annotate( - allocated = Coalesce( + allocated=Coalesce( Sum('sales_order_allocations__quantity', distinct=True), 0) + Coalesce( Sum('allocations__quantity', distinct=True), 0), - tracking_items = Count('tracking_info'), + tracking_items=Count('tracking_info'), ) return queryset From cc11df917e321d8ac421c08229ee83be084194e8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 May 2020 14:03:17 +1000 Subject: [PATCH 13/14] Part list API adjustments --- InvenTree/part/api.py | 13 +++++++++++-- InvenTree/part/serializers.py | 13 ++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 335853331c..6f5327baa4 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -6,7 +6,7 @@ Provides a JSON API for the Part app from __future__ import unicode_literals from django_filters.rest_framework import DjangoFilterBackend - +from django.http import JsonResponse from django.db.models import Q, F, Count from rest_framework import status @@ -264,7 +264,15 @@ class PartList(generics.ListCreateAPIView): part['category_detail'] = detail - return Response(data) + """ + Determine the response type based on the request. + a) For HTTP requests (e.g. via the browseable API) return a DRF response + b) For AJAX requests, simply return a JSON rendered response. + """ + if request.is_ajax(): + return JsonResponse(data, safe=False) + else: + return Response(data) def create(self, request, *args, **kwargs): """ Override the default 'create' behaviour: @@ -288,6 +296,7 @@ class PartList(generics.ListCreateAPIView): queryset = super().get_queryset(*args, **kwargs) queryset = part_serializers.PartSerializer.prefetch_queryset(queryset) + queryset = part_serializers.PartSerializer.annotate_queryset(queryset) return queryset diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 86a3189c43..920e0486c3 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -130,12 +130,7 @@ class PartSerializer(InvenTreeModelSerializer): # Annotate the number total stock count queryset = queryset.annotate( - in_stock=Coalesce(Sum('stock_items__quantity', filter=stock_filter, distinct=True), Decimal(0)) - ) - - # Annotate the number of parts "on order" - # Total "on order" parts = "Quantity" - "Received" for each active purchase order - queryset = queryset.annotate( + in_stock=Coalesce(Sum('stock_items__quantity', filter=stock_filter, distinct=True), Decimal(0)), ordering=Coalesce(Sum( 'supplier_parts__purchase_order_line_items__quantity', filter=order_filter, @@ -144,11 +139,7 @@ class PartSerializer(InvenTreeModelSerializer): 'supplier_parts__purchase_order_line_items__received', filter=order_filter, distinct=True - ), Decimal(0)) - ) - - # Annotate number of parts being build - queryset = queryset.annotate( + ), Decimal(0)), building=Coalesce( Sum('builds__quantity', filter=build_filter, distinct=True), Decimal(0) ) From 9b2045025b0b6250743fe520991ef30c301152b0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 2 May 2020 14:05:52 +1000 Subject: [PATCH 14/14] StockItem page tweaks --- InvenTree/stock/templates/stock/item_base.html | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index bfbc737181..4e2d6b7439 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -105,9 +105,8 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} - Part + {% trans "Base Part" %} - {% include "hover_image.html" with image=item.part.image hover=True %} {{ item.part.full_name }} @@ -145,7 +144,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% endif %} {% if item.serialized %} - + {% trans "Serial Number" %} {{ item.serial }}