mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge pull request #738 from SchrodingersGat/stock-count-fix
Stock count fix
This commit is contained in:
		| @@ -87,6 +87,9 @@ function loadPartTable(table, url, options={}) { | ||||
|      *      disableFilters: If true, disable custom filters | ||||
|      */ | ||||
|  | ||||
|     // Ensure category detail is included | ||||
|     options.params['category_detail'] = true; | ||||
|  | ||||
|     var params = options.params || {}; | ||||
|  | ||||
|     var filters = {}; | ||||
| @@ -184,11 +187,11 @@ function loadPartTable(table, url, options={}) { | ||||
|      | ||||
|     columns.push({ | ||||
|         sortable: true, | ||||
|         field: 'category__name', | ||||
|         field: 'category_detail', | ||||
|         title: 'Category', | ||||
|         formatter: function(value, row, index, field) { | ||||
|             if (row.category) { | ||||
|                 return renderLink(row.category__name, "/part/category/" + row.category + "/"); | ||||
|                 return renderLink(value.pathstring, "/part/category/" + row.category + "/"); | ||||
|             } | ||||
|             else { | ||||
|                 return 'No category'; | ||||
|   | ||||
| @@ -45,6 +45,10 @@ function loadStockTable(table, options) { | ||||
|      */ | ||||
|  | ||||
|     // List of user-params which override the default filters | ||||
|  | ||||
|     options.params['part_detail'] = true; | ||||
|     options.params['location_detail'] = true; | ||||
|  | ||||
|     var params = options.params || {}; | ||||
|  | ||||
|     var filterListElement = options.filterList || "#filter-list-stock"; | ||||
| @@ -83,27 +87,21 @@ function loadStockTable(table, options) { | ||||
|  | ||||
|             var row = data[0]; | ||||
|  | ||||
|             if (field == 'part__name') { | ||||
|             if (field == 'part_name') { | ||||
|  | ||||
|                 var name = row.part__IPN; | ||||
|                 var name = row.part_detail.full_name; | ||||
|  | ||||
|                 if (name) { | ||||
|                     name += ' | '; | ||||
|                 return imageHoverIcon(row.part_detail.thumbnail) + name + ' <i>(' + data.length + ' items)</i>'; | ||||
|             } | ||||
|  | ||||
|                 name += row.part__name; | ||||
|  | ||||
|                 return imageHoverIcon(row.part__thumbnail) + name + ' <i>(' + data.length + ' items)</i>'; | ||||
|             } | ||||
|             else if (field == 'part__description') { | ||||
|                 return row.part__description; | ||||
|             else if (field == 'part_description') { | ||||
|                 return row.part_detail.description; | ||||
|             } | ||||
|             else if (field == 'quantity') { | ||||
|                 var stock = 0; | ||||
|                 var items = 0; | ||||
|  | ||||
|                 data.forEach(function(item) { | ||||
|                     stock += item.quantity;  | ||||
|                     stock += parseFloat(item.quantity);  | ||||
|                     items += 1; | ||||
|                 }); | ||||
|  | ||||
| @@ -216,25 +214,14 @@ function loadStockTable(table, options) { | ||||
|                 visible: false, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'part__name', | ||||
|                 field: 'part_name', | ||||
|                 title: 'Part', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row, index, field) { | ||||
|  | ||||
|                     var name = row.part__IPN; | ||||
|  | ||||
|                     if (name) { | ||||
|                         name += ' | '; | ||||
|                     } | ||||
|  | ||||
|                     name += row.part__name; | ||||
|  | ||||
|                     if (row.part__revision) { | ||||
|                         name += " | "; | ||||
|                         name += row.part__revision; | ||||
|                     } | ||||
|  | ||||
|                     var url = ''; | ||||
|                     var thumb = row.part_detail.thumbnail; | ||||
|                     var name = row.part_detail.full_name; | ||||
|  | ||||
|                     if (row.supplier_part) { | ||||
|                         url = `/supplier-part/${row.supplier_part}/`; | ||||
| @@ -242,13 +229,16 @@ function loadStockTable(table, options) { | ||||
|                         url = `/part/${row.part}/`; | ||||
|                     } | ||||
|                      | ||||
|                     return imageHoverIcon(row.part__thumbnail) + renderLink(name, url); | ||||
|                     return imageHoverIcon(thumb) + renderLink(name, url); | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'part__description', | ||||
|                 field: 'part_description', | ||||
|                 title: 'Description', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row, index, field) { | ||||
|                     return row.part_detail.description; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'quantity', | ||||
| @@ -256,11 +246,13 @@ function loadStockTable(table, options) { | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row, index, field) { | ||||
|  | ||||
|                     var val = value; | ||||
|                     var val = parseFloat(value); | ||||
|  | ||||
|                     // If there is a single unit with a serial number, use the serial number | ||||
|                     if (row.serial && row.quantity == 1) { | ||||
|                         val = '# ' + row.serial; | ||||
|                     } else { | ||||
|                         val = +val.toFixed(5); | ||||
|                     } | ||||
|  | ||||
|                     var text = renderLink(val, '/stock/item/' + row.pk + '/'); | ||||
| @@ -282,7 +274,7 @@ function loadStockTable(table, options) { | ||||
|                 sortable: true, | ||||
|             }, | ||||
|             { | ||||
|                 field: 'location__path', | ||||
|                 field: 'location_detail.pathstring', | ||||
|                 title: 'Location', | ||||
|                 sortable: true, | ||||
|                 formatter: function(value, row, index, field) { | ||||
|   | ||||
| @@ -4,8 +4,9 @@ Provides information on the current InvenTree version | ||||
|  | ||||
| import subprocess | ||||
| from common.models import InvenTreeSetting | ||||
| import django | ||||
|  | ||||
| INVENTREE_SW_VERSION = "0.0.11_pre" | ||||
| INVENTREE_SW_VERSION = "0.0.12 pre" | ||||
|  | ||||
|  | ||||
| def inventreeInstanceName(): | ||||
| @@ -18,6 +19,11 @@ def inventreeVersion(): | ||||
|     return INVENTREE_SW_VERSION | ||||
|  | ||||
|  | ||||
| def inventreeDjangoVersion(): | ||||
|     """ Return the version of Django library """ | ||||
|     return django.get_version() | ||||
|  | ||||
|  | ||||
| def inventreeCommitHash(): | ||||
|     """ Returns the git commit hash for the running codebase """ | ||||
|  | ||||
|   | ||||
| @@ -6,10 +6,8 @@ Provides a JSON API for the Part app | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from django.conf import settings | ||||
|  | ||||
| from django.db.models import Q, F, Sum, Count | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models import Q, F, Count | ||||
|  | ||||
| from rest_framework import status | ||||
| from rest_framework.response import Response | ||||
| @@ -19,15 +17,11 @@ from rest_framework import generics, permissions | ||||
| from django.conf.urls import url, include | ||||
| from django.urls import reverse | ||||
|  | ||||
| import os | ||||
| from decimal import Decimal | ||||
|  | ||||
| from .models import Part, PartCategory, BomItem, PartStar | ||||
| from .models import PartParameter, PartParameterTemplate | ||||
|  | ||||
| from . import serializers as part_serializers | ||||
|  | ||||
| from InvenTree.status_codes import OrderStatus, StockStatus, BuildStatus | ||||
| from InvenTree.views import TreeSerializer | ||||
| from InvenTree.helpers import str2bool, isNull | ||||
|  | ||||
| @@ -125,6 +119,8 @@ class PartThumbs(generics.ListAPIView): | ||||
|         # Get all Parts which have an associated image | ||||
|         queryset = Part.objects.all().exclude(image='') | ||||
|  | ||||
|         # TODO - We should return the thumbnails here, not the full image! | ||||
|  | ||||
|         # Return the most popular parts first | ||||
|         data = queryset.values( | ||||
|             'image', | ||||
| @@ -166,6 +162,31 @@ class PartList(generics.ListCreateAPIView): | ||||
|  | ||||
|     serializer_class = part_serializers.PartSerializer | ||||
|  | ||||
|     queryset = Part.objects.all() | ||||
|  | ||||
|     starred_parts = None | ||||
|  | ||||
|     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() | ||||
|  | ||||
|         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: | ||||
|             self.starred_parts = [star.part for star in self.request.user.starred_parts.all()] | ||||
|  | ||||
|         kwargs['starred_parts'] = self.starred_parts | ||||
|  | ||||
|         return self.serializer_class(*args, **kwargs) | ||||
|  | ||||
|     def create(self, request, *args, **kwargs): | ||||
|         """ Override the default 'create' behaviour: | ||||
|         We wish to save the user who created this part! | ||||
| @@ -184,129 +205,20 @@ class PartList(generics.ListCreateAPIView): | ||||
|         headers = self.get_success_headers(serializer.data) | ||||
|         return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) | ||||
|  | ||||
|     def list(self, request, *args, **kwargs): | ||||
|     def get_queryset(self, *args, **kwargs): | ||||
|  | ||||
|         queryset = super().get_queryset(*args, **kwargs) | ||||
|         queryset = part_serializers.PartSerializer.prefetch_queryset(queryset) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     def filter_queryset(self, queryset): | ||||
|         """ | ||||
|         Instead of using the DRF serialiser to LIST, | ||||
|         we serialize the objects manually. | ||||
|         This turns out to be significantly faster. | ||||
|         Perform custom filtering of the queryset | ||||
|         """ | ||||
|  | ||||
|         queryset = self.filter_queryset(self.get_queryset()) | ||||
|  | ||||
|         # Filters for annotations | ||||
|  | ||||
|         # "in_stock" count should only sum stock items which are "in stock" | ||||
|         stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES) | ||||
|  | ||||
|         # "on_order" items should only sum orders which are currently outstanding | ||||
|         order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=OrderStatus.OPEN) | ||||
|  | ||||
|         # "building" should only reference builds which are active | ||||
|         build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES) | ||||
|  | ||||
|         # Set of fields we wish to serialize | ||||
|         data = queryset.values( | ||||
|             'pk', | ||||
|             'category', | ||||
|             'image', | ||||
|             'name', | ||||
|             'IPN', | ||||
|             'revision', | ||||
|             'description', | ||||
|             'keywords', | ||||
|             'is_template', | ||||
|             'link', | ||||
|             'units', | ||||
|             'minimum_stock', | ||||
|             'trackable', | ||||
|             'assembly', | ||||
|             'component', | ||||
|             'salable', | ||||
|             'active', | ||||
|         ).annotate( | ||||
|             # Quantity of items which are "in stock" | ||||
|             in_stock=Coalesce(Sum('stock_items__quantity', filter=stock_filter), Decimal(0)), | ||||
|             on_order=Coalesce(Sum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter), Decimal(0)), | ||||
|             building=Coalesce(Sum('builds__quantity', filter=build_filter), Decimal(0)), | ||||
|         ) | ||||
|  | ||||
|         # If we are filtering by 'has_stock' status | ||||
|         has_stock = self.request.query_params.get('has_stock', None) | ||||
|  | ||||
|         if has_stock is not None: | ||||
|             has_stock = str2bool(has_stock) | ||||
|  | ||||
|             if has_stock: | ||||
|                 # Filter items which have a non-null 'in_stock' quantity above zero | ||||
|                 data = data.filter(in_stock__gt=0) | ||||
|             else: | ||||
|                 # Filter items which a null or zero 'in_stock' quantity | ||||
|                 data = data.filter(Q(in_stock__lte=0)) | ||||
|  | ||||
|         # If we are filtering by 'low_stock' status | ||||
|         low_stock = self.request.query_params.get('low_stock', None) | ||||
|  | ||||
|         if low_stock is not None: | ||||
|             low_stock = str2bool(low_stock) | ||||
|  | ||||
|             if low_stock: | ||||
|                 # Ignore any parts which do not have a specified 'minimum_stock' level | ||||
|                 data = data.exclude(minimum_stock=0) | ||||
|                 # Filter items which have an 'in_stock' level lower than 'minimum_stock' | ||||
|                 data = data.filter(Q(in_stock__lt=F('minimum_stock'))) | ||||
|             else: | ||||
|                 # Filter items which have an 'in_stock' level higher than 'minimum_stock' | ||||
|                 data = data.filter(Q(in_stock__gte=F('minimum_stock'))) | ||||
|  | ||||
|         # Get a list of the parts that this user has starred | ||||
|         starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()] | ||||
|  | ||||
|         # Reduce the number of lookups we need to do for the part categories | ||||
|         categories = {} | ||||
|  | ||||
|         for item in data: | ||||
|  | ||||
|             if item['image']: | ||||
|                 # Is this part 'starred' for the current user? | ||||
|                 item['starred'] = item['pk'] in starred_parts | ||||
|  | ||||
|                 img = item['image'] | ||||
|  | ||||
|                 # Use the 'thumbnail' image here instead of the full-size image | ||||
|                 # Note: The full-size image is used when requesting the /api/part/<x>/ endpoint | ||||
|  | ||||
|                 if img: | ||||
|                     fn, ext = os.path.splitext(img) | ||||
|  | ||||
|                     thumb = "{fn}.thumbnail{ext}".format(fn=fn, ext=ext) | ||||
|  | ||||
|                     thumb = os.path.join(settings.MEDIA_URL, thumb) | ||||
|                 else: | ||||
|                     thumb = '' | ||||
|  | ||||
|                 item['thumbnail'] = thumb | ||||
|  | ||||
|                 del item['image'] | ||||
|  | ||||
|             cat_id = item['category'] | ||||
|  | ||||
|             if cat_id: | ||||
|                 if cat_id not in categories: | ||||
|                     categories[cat_id] = PartCategory.objects.get(pk=cat_id).pathstring | ||||
|  | ||||
|                 item['category__name'] = categories[cat_id] | ||||
|             else: | ||||
|                 item['category__name'] = None | ||||
|  | ||||
|         return Response(data) | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         """ | ||||
|         Implement custom filtering for the Part list API | ||||
|         """ | ||||
|  | ||||
|         # Start with all objects | ||||
|         parts_list = Part.objects.all() | ||||
|         # Perform basic filtering | ||||
|         queryset = super().filter_queryset(queryset) | ||||
|  | ||||
|         # Filter by 'starred' parts? | ||||
|         starred = str2bool(self.request.query_params.get('starred', None)) | ||||
| @@ -315,10 +227,11 @@ class PartList(generics.ListCreateAPIView): | ||||
|             starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()] | ||||
|  | ||||
|             if starred: | ||||
|                 parts_list = parts_list.filter(pk__in=starred_parts) | ||||
|                 queryset = queryset.filter(pk__in=starred_parts) | ||||
|             else: | ||||
|                 parts_list = parts_list.exclude(pk__in=starred_parts) | ||||
|                 queryset = queryset.exclude(pk__in=starred_parts) | ||||
|  | ||||
|         # Cascade? | ||||
|         cascade = str2bool(self.request.query_params.get('cascade', None)) | ||||
|  | ||||
|         # Does the user wish to filter by category? | ||||
| @@ -334,7 +247,7 @@ class PartList(generics.ListCreateAPIView): | ||||
|                 # A 'null' category is the top-level category | ||||
|                 if cascade is False: | ||||
|                     # Do not cascade, only list parts in the top-level category | ||||
|                     parts_list = parts_list.filter(category=None) | ||||
|                     queryset = queryset.filter(category=None) | ||||
|  | ||||
|             else: | ||||
|                 try: | ||||
| @@ -342,17 +255,43 @@ class PartList(generics.ListCreateAPIView): | ||||
|  | ||||
|                     # If '?cascade=true' then include parts which exist in sub-categories | ||||
|                     if cascade: | ||||
|                         parts_list = parts_list.filter(category__in=category.getUniqueChildren()) | ||||
|                         queryset = queryset.filter(category__in=category.getUniqueChildren()) | ||||
|                     # Just return parts directly in the requested category | ||||
|                     else: | ||||
|                         parts_list = parts_list.filter(category=cat_id) | ||||
|                         queryset = queryset.filter(category=cat_id) | ||||
|                 except (ValueError, PartCategory.DoesNotExist): | ||||
|                     pass | ||||
|  | ||||
|         # Ensure that related models are pre-loaded to reduce DB trips | ||||
|         parts_list = self.get_serializer_class().setup_eager_loading(parts_list) | ||||
|         # Annotate calculated data to the queryset | ||||
|         # (This will be used for further filtering) | ||||
|         queryset = part_serializers.PartSerializer.annotate_queryset(queryset) | ||||
|  | ||||
|         return parts_list | ||||
|         # Filter by whether the part has stock | ||||
|         has_stock = self.request.query_params.get("has_stock", None) | ||||
|         if has_stock is not None: | ||||
|             has_stock = str2bool(has_stock) | ||||
|  | ||||
|             if has_stock: | ||||
|                 queryset = queryset.filter(Q(in_stock__gt=0)) | ||||
|             else: | ||||
|                 queryset = queryset.filter(Q(in_stock__lte=0)) | ||||
|  | ||||
|         # If we are filtering by 'low_stock' status | ||||
|         low_stock = self.request.query_params.get('low_stock', None) | ||||
|  | ||||
|         if low_stock is not None: | ||||
|             low_stock = str2bool(low_stock) | ||||
|  | ||||
|             if low_stock: | ||||
|                 # Ignore any parts which do not have a specified 'minimum_stock' level | ||||
|                 queryset = queryset.exclude(minimum_stock=0) | ||||
|                 # Filter items which have an 'in_stock' level lower than 'minimum_stock' | ||||
|                 queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock'))) | ||||
|             else: | ||||
|                 # Filter items which have an 'in_stock' level higher than 'minimum_stock' | ||||
|                 queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock'))) | ||||
|  | ||||
|         return queryset | ||||
|  | ||||
|     permission_classes = [ | ||||
|         permissions.IsAuthenticated, | ||||
| @@ -379,6 +318,7 @@ class PartList(generics.ListCreateAPIView): | ||||
|         'name', | ||||
|     ] | ||||
|  | ||||
|     # Default ordering | ||||
|     ordering = 'name' | ||||
|  | ||||
|     search_fields = [ | ||||
| @@ -507,7 +447,9 @@ class BomList(generics.ListCreateAPIView): | ||||
|         kwargs['part_detail'] = part_detail | ||||
|         kwargs['sub_part_detail'] = sub_part_detail | ||||
|  | ||||
|         # Ensure the request context is passed through! | ||||
|         kwargs['context'] = self.get_serializer_context() | ||||
|          | ||||
|         return self.serializer_class(*args, **kwargs) | ||||
|  | ||||
|     def get_queryset(self): | ||||
|   | ||||
| @@ -10,6 +10,12 @@ from .models import PartCategory | ||||
| from .models import BomItem | ||||
| from .models import PartParameter, PartParameterTemplate | ||||
|  | ||||
| from decimal import Decimal | ||||
|  | ||||
| from django.db.models import Q, Sum | ||||
| from django.db.models.functions import Coalesce | ||||
|  | ||||
| from InvenTree.status_codes import StockStatus, OrderStatus, BuildStatus | ||||
| from InvenTree.serializers import InvenTreeModelSerializer | ||||
|  | ||||
|  | ||||
| @@ -49,14 +55,6 @@ class PartBriefSerializer(InvenTreeModelSerializer): | ||||
|     url = serializers.CharField(source='get_absolute_url', read_only=True) | ||||
|     thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) | ||||
|      | ||||
|     @staticmethod | ||||
|     def setup_eager_loading(queryset): | ||||
|         queryset = queryset.prefetch_related('category') | ||||
|         queryset = queryset.prefetch_related('stock_items') | ||||
|         queryset = queryset.prefetch_related('bom_items') | ||||
|         queryset = queryset.prefetch_related('builds') | ||||
|         return queryset | ||||
|      | ||||
|     class Meta: | ||||
|         model = Part | ||||
|         fields = [ | ||||
| @@ -64,8 +62,6 @@ class PartBriefSerializer(InvenTreeModelSerializer): | ||||
|             'url', | ||||
|             'full_name', | ||||
|             'description', | ||||
|             'total_stock', | ||||
|             'available_stock', | ||||
|             'thumbnail', | ||||
|             'active', | ||||
|             'assembly', | ||||
| @@ -78,57 +74,140 @@ class PartSerializer(InvenTreeModelSerializer): | ||||
|     Used when displaying all details of a single component. | ||||
|     """ | ||||
|  | ||||
|     allocated_stock = serializers.FloatField(source='allocation_count', read_only=True) | ||||
|     bom_items = serializers.IntegerField(source='bom_count', read_only=True) | ||||
|     building = serializers.FloatField(source='quantity_being_built', read_only=False) | ||||
|     category_name = serializers.CharField(source='category_path', read_only=True) | ||||
|     image = serializers.CharField(source='get_image_url', read_only=True) | ||||
|     on_order = serializers.FloatField(read_only=True) | ||||
|     thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) | ||||
|     url = serializers.CharField(source='get_absolute_url', read_only=True) | ||||
|     used_in = serializers.IntegerField(source='used_in_count', read_only=True) | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         """ | ||||
|         Custom initialization method for PartSerializer, | ||||
|         so that we can optionally pass extra fields based on the query. | ||||
|         """ | ||||
|  | ||||
|         self.starred_parts = kwargs.pop('starred_parts', []) | ||||
|  | ||||
|         category_detail = kwargs.pop('category_detail', False) | ||||
|  | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|         if category_detail is not True: | ||||
|             self.fields.pop('category_detail') | ||||
|  | ||||
|     @staticmethod | ||||
|     def setup_eager_loading(queryset): | ||||
|         queryset = queryset.prefetch_related('category') | ||||
|         queryset = queryset.prefetch_related('stock_items') | ||||
|         queryset = queryset.prefetch_related('bom_items') | ||||
|         queryset = queryset.prefetch_related('builds') | ||||
|     def prefetch_queryset(queryset): | ||||
|         """ | ||||
|         Prefetch related database tables, | ||||
|         to reduce database hits. | ||||
|         """ | ||||
|  | ||||
|         return queryset.prefetch_related( | ||||
|             'category', | ||||
|             'stock_items', | ||||
|             'bom_items', | ||||
|             'builds', | ||||
|             'supplier_parts', | ||||
|             'supplier_parts__purchase_order_line_items', | ||||
|             'supplier_parts__purchase_order_line_items__order', | ||||
|         ) | ||||
|  | ||||
|     @staticmethod | ||||
|     def annotate_queryset(queryset): | ||||
|         """ | ||||
|         Add some extra annotations to the queryset, | ||||
|         performing database queries as efficiently as possible, | ||||
|         to reduce database trips. | ||||
|         """ | ||||
|  | ||||
|         # Filter to limit stock items to "available" | ||||
|         stock_filter = Q(stock_items__status__in=StockStatus.AVAILABLE_CODES) | ||||
|  | ||||
|         # Filter to limit orders to "open" | ||||
|         order_filter = Q(supplier_parts__purchase_order_line_items__order__status__in=OrderStatus.OPEN) | ||||
|  | ||||
|         # Filter to limit builds to "active" | ||||
|         build_filter = Q(builds__status__in=BuildStatus.ACTIVE_CODES) | ||||
|  | ||||
|         # 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( | ||||
|             ordering=Coalesce(Sum( | ||||
|                 'supplier_parts__purchase_order_line_items__quantity', | ||||
|                 filter=order_filter, | ||||
|                 distinct=True | ||||
|             ), Decimal(0)) - Coalesce(Sum( | ||||
|                 'supplier_parts__purchase_order_line_items__received', | ||||
|                 filter=order_filter, | ||||
|                 distinct=True | ||||
|             ), Decimal(0)) | ||||
|         ) | ||||
|  | ||||
|         # Annotate number of parts being build | ||||
|         queryset = queryset.annotate( | ||||
|             building=Coalesce( | ||||
|                 Sum('builds__quantity', filter=build_filter, distinct=True), Decimal(0) | ||||
|             ) | ||||
|         ) | ||||
|          | ||||
|         return queryset | ||||
|  | ||||
|     # TODO - Include a 'category_detail' field which serializers the category object | ||||
|     def get_starred(self, part): | ||||
|         """ | ||||
|         Return "true" if the part is starred by the current user. | ||||
|         """ | ||||
|  | ||||
|         return part in self.starred_parts | ||||
|  | ||||
|     # Extra detail for the category | ||||
|     category_detail = CategorySerializer(source='category', many=False, read_only=True) | ||||
|  | ||||
|     # Calculated fields | ||||
|     in_stock = serializers.FloatField(read_only=True) | ||||
|     ordering = serializers.FloatField(read_only=True) | ||||
|     building = serializers.FloatField(read_only=True) | ||||
|  | ||||
|     image = serializers.CharField(source='get_image_url', read_only=True) | ||||
|     thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) | ||||
|     starred = serializers.SerializerMethodField() | ||||
|  | ||||
|     # TODO - Include annotation for the following fields: | ||||
|     # allocated_stock = serializers.FloatField(source='allocation_count', read_only=True) | ||||
|     # bom_items = serializers.IntegerField(source='bom_count', read_only=True) | ||||
|     # used_in = serializers.IntegerField(source='used_in_count', read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Part | ||||
|         partial = True | ||||
|         fields = [ | ||||
|             'active', | ||||
|             'allocated_stock', | ||||
|             # 'allocated_stock', | ||||
|             'assembly', | ||||
|             'bom_items', | ||||
|             'building', | ||||
|             # 'bom_items', | ||||
|             'category', | ||||
|             'category_name', | ||||
|             'category_detail', | ||||
|             'component', | ||||
|             'description', | ||||
|             'full_name', | ||||
|             'image', | ||||
|             'in_stock', | ||||
|             'ordering', | ||||
|             'building', | ||||
|             'IPN', | ||||
|             'is_template', | ||||
|             'keywords', | ||||
|             'link', | ||||
|             'minimum_stock', | ||||
|             'name', | ||||
|             'notes', | ||||
|             'on_order', | ||||
|             'pk', | ||||
|             'purchaseable', | ||||
|             'revision', | ||||
|             'salable', | ||||
|             'starred', | ||||
|             'thumbnail', | ||||
|             'trackable', | ||||
|             'total_stock', | ||||
|             'units', | ||||
|             'used_in', | ||||
|             'url',  # Link to the part detail page | ||||
|             # 'used_in', | ||||
|             'variant_of', | ||||
|             'virtual', | ||||
|         ] | ||||
|   | ||||
| @@ -55,6 +55,12 @@ def inventree_version(*args, **kwargs): | ||||
|     return version.inventreeVersion() | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def django_version(*args, **kwargs): | ||||
|     """ Return Django version string """ | ||||
|     return version.inventreeDjangoVersion() | ||||
|  | ||||
|  | ||||
| @register.simple_tag() | ||||
| def inventree_commit_hash(*args, **kwargs): | ||||
|     """ Return InvenTree git commit hash string """ | ||||
|   | ||||
| @@ -5,7 +5,6 @@ JSON API for the Stock app | ||||
| from django_filters.rest_framework import FilterSet, DjangoFilterBackend | ||||
| from django_filters import NumberFilter | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.conf.urls import url, include | ||||
| from django.urls import reverse | ||||
| from django.db.models import Q | ||||
| @@ -21,9 +20,7 @@ from .serializers import StockTrackingSerializer | ||||
|  | ||||
| from InvenTree.views import TreeSerializer | ||||
| from InvenTree.helpers import str2bool, isNull | ||||
| from InvenTree.status_codes import StockStatus | ||||
|  | ||||
| import os | ||||
| from decimal import Decimal, InvalidOperation | ||||
|  | ||||
| from rest_framework.serializers import ValidationError | ||||
| @@ -317,13 +314,15 @@ class StockList(generics.ListCreateAPIView): | ||||
|         - status: Filter by the StockItem status | ||||
|     """ | ||||
|  | ||||
|     serializer_class = StockItemSerializer | ||||
|  | ||||
|     queryset = StockItem.objects.all() | ||||
|  | ||||
|     def get_serializer(self, *args, **kwargs): | ||||
|  | ||||
|         try: | ||||
|             part_detail = str2bool(self.request.GET.get('part_detail', None)) | ||||
|             location_detail = str2bool(self.request.GET.get('location_detail', None)) | ||||
|             part_detail = str2bool(self.request.query_params.get('part_detail', None)) | ||||
|             location_detail = str2bool(self.request.query_params.get('location_detail', None)) | ||||
|         except AttributeError: | ||||
|             part_detail = None | ||||
|             location_detail = None | ||||
| @@ -331,86 +330,25 @@ class StockList(generics.ListCreateAPIView): | ||||
|         kwargs['part_detail'] = part_detail | ||||
|         kwargs['location_detail'] = location_detail | ||||
|          | ||||
|         # Ensure the request context is passed through | ||||
|         kwargs['context'] = self.get_serializer_context() | ||||
|  | ||||
|         return self.serializer_class(*args, **kwargs) | ||||
|  | ||||
|     def list(self, request, *args, **kwargs): | ||||
|     # TODO - Override the 'create' method for this view, | ||||
|     # to allow the user to be recorded when a new StockItem object is created | ||||
|  | ||||
|         queryset = self.filter_queryset(self.get_queryset()) | ||||
|     def get_queryset(self, *args, **kwargs): | ||||
|  | ||||
|         # Instead of using the DRF serializer to LIST, | ||||
|         # we will serialize the objects manually. | ||||
|         # This is significantly faster | ||||
|         queryset = super().get_queryset(*args, **kwargs) | ||||
|         queryset = StockItemSerializer.prefetch_queryset(queryset) | ||||
|  | ||||
|         data = queryset.values( | ||||
|             'pk', | ||||
|             'uid', | ||||
|             'parent', | ||||
|             'quantity', | ||||
|             'serial', | ||||
|             'batch', | ||||
|             'status', | ||||
|             'notes', | ||||
|             'link', | ||||
|             'location', | ||||
|             'location__name', | ||||
|             'location__description', | ||||
|             'part', | ||||
|             'part__IPN', | ||||
|             'part__name', | ||||
|             'part__revision', | ||||
|             'part__description', | ||||
|             'part__image', | ||||
|             'part__category', | ||||
|             'part__category__name', | ||||
|             'part__category__description', | ||||
|             'supplier_part', | ||||
|         ) | ||||
|         return queryset | ||||
|  | ||||
|         # Reduce the number of lookups we need to do for categories | ||||
|         # Cache location lookups for this query | ||||
|         locations = {} | ||||
|  | ||||
|         for item in data: | ||||
|  | ||||
|             img = item['part__image'] | ||||
|  | ||||
|             if img: | ||||
|                 # Use the thumbnail image instead | ||||
|                 fn, ext = os.path.splitext(img) | ||||
|  | ||||
|                 thumb = "{fn}.thumbnail{ext}".format(fn=fn, ext=ext) | ||||
|  | ||||
|                 thumb = os.path.join(settings.MEDIA_URL, thumb) | ||||
|             else: | ||||
|                 thumb = '' | ||||
|  | ||||
|             item['part__thumbnail'] = thumb | ||||
|  | ||||
|             del item['part__image'] | ||||
|  | ||||
|             loc_id = item['location'] | ||||
|  | ||||
|             if loc_id: | ||||
|                 if loc_id not in locations: | ||||
|                     locations[loc_id] = StockLocation.objects.get(pk=loc_id).pathstring | ||||
|                  | ||||
|                 item['location__path'] = locations[loc_id] | ||||
|             else: | ||||
|                 item['location__path'] = None | ||||
|  | ||||
|             item['status_text'] = StockStatus.label(item['status']) | ||||
|  | ||||
|         return Response(data) | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         """ | ||||
|         If the query includes a particular location, | ||||
|         we may wish to also request stock items from all child locations. | ||||
|         """ | ||||
|     def filter_queryset(self, queryset): | ||||
|  | ||||
|         # Start with all objects | ||||
|         stock_list = super(StockList, self).get_queryset() | ||||
|         stock_list = super().filter_queryset(queryset) | ||||
|          | ||||
|         # Filter out parts which are not actually "in stock" | ||||
|         stock_list = stock_list.filter(customer=None, belongs_to=None) | ||||
|   | ||||
| @@ -8,7 +8,6 @@ from .models import StockItem, StockLocation | ||||
| from .models import StockItemTracking | ||||
|  | ||||
| from part.serializers import PartBriefSerializer | ||||
| from company.serializers import SupplierPartSerializer | ||||
| from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer | ||||
|  | ||||
|  | ||||
| @@ -56,24 +55,43 @@ class StockItemSerializer(InvenTreeModelSerializer): | ||||
|     - Includes serialization for the item location | ||||
|     """ | ||||
|  | ||||
|     url = serializers.CharField(source='get_absolute_url', read_only=True) | ||||
|     @staticmethod | ||||
|     def prefetch_queryset(queryset): | ||||
|         """ | ||||
|         Prefetch related database tables, | ||||
|         to reduce database hits. | ||||
|         """ | ||||
|  | ||||
|         return queryset.prefetch_related( | ||||
|             'supplier_part', | ||||
|             'supplier_part__supplier', | ||||
|             'supplier_part__manufacturer', | ||||
|             'location', | ||||
|             'part', | ||||
|             'tracking_info', | ||||
|         ) | ||||
|  | ||||
|     @staticmethod | ||||
|     def annotate_queryset(queryset): | ||||
|         """ | ||||
|         Add some extra annotations to the queryset, | ||||
|         performing database queries as efficiently as possible. | ||||
|         """ | ||||
|  | ||||
|         # TODO | ||||
|         pass | ||||
|  | ||||
|     status_text = serializers.CharField(source='get_status_display', read_only=True) | ||||
|      | ||||
|     part_name = serializers.CharField(source='get_part_name', read_only=True) | ||||
|  | ||||
|     part_image = serializers.CharField(source='part__image', read_only=True) | ||||
|  | ||||
|     tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True) | ||||
|      | ||||
|     part_detail = PartBriefSerializer(source='part', many=False, read_only=True) | ||||
|     location_detail = LocationBriefSerializer(source='location', many=False, read_only=True) | ||||
|     supplier_detail = SupplierPartSerializer(source='supplier_part', many=False, read_only=True) | ||||
|  | ||||
|     tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True) | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|  | ||||
|         part_detail = kwargs.pop('part_detail', False) | ||||
|         location_detail = kwargs.pop('location_detail', False) | ||||
|         supplier_detail = kwargs.pop('supplier_detail', False) | ||||
|  | ||||
|         super(StockItemSerializer, self).__init__(*args, **kwargs) | ||||
|  | ||||
| @@ -83,9 +101,6 @@ class StockItemSerializer(InvenTreeModelSerializer): | ||||
|         if location_detail is not True: | ||||
|             self.fields.pop('location_detail') | ||||
|  | ||||
|         if supplier_detail is not True: | ||||
|             self.fields.pop('supplier_detail') | ||||
|  | ||||
|     class Meta: | ||||
|         model = StockItem | ||||
|         fields = [ | ||||
| @@ -97,18 +112,14 @@ class StockItemSerializer(InvenTreeModelSerializer): | ||||
|             'notes', | ||||
|             'part', | ||||
|             'part_detail', | ||||
|             'part_name', | ||||
|             'part_image', | ||||
|             'pk', | ||||
|             'quantity', | ||||
|             'serial', | ||||
|             'supplier_part', | ||||
|             'supplier_detail', | ||||
|             'status', | ||||
|             'status_text', | ||||
|             'tracking_items', | ||||
|             'uid', | ||||
|             'url', | ||||
|         ] | ||||
|  | ||||
|         """ These fields are read-only in this context. | ||||
|   | ||||
| @@ -25,6 +25,10 @@ | ||||
|                             <td><span class='fas fa-hashtag'></span></td> | ||||
|                             <td>{% trans "InvenTree Version" %}</td><td><a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a></td> | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td><span class='fas fa-hashtag'></span></td> | ||||
|                             <td>{% trans "Django Version" %}</td><td><a href="https://www.djangoproject.com/">{% django_version %}</a></td> | ||||
|                         </tr> | ||||
|                         <tr> | ||||
|                             <td><span class='fas fa-code-branch'></span></td> | ||||
|                             <td>{% trans "Commit Hash" %}</td><td><a href="https://github.com/inventree/InvenTree/commit/{% inventree_commit_hash %}">{% inventree_commit_hash %}</a></td> | ||||
| @@ -45,7 +49,7 @@ | ||||
|                         <tr> | ||||
|                             <td><span class='fas fa-exclamation-circle'></span></td> | ||||
|                             <td>{% trans "Submit Bug Report" %}</td> | ||||
|                             <td><a href='{% inventree_github_url %}/issues'>{% inventree_github_url %}/issues</a></td> | ||||
|                             <td><a href='{% inventree_github_url %}/issues'>{% inventree_github_url %}issues</a></td> | ||||
|                         </tr> | ||||
|                     </table> | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| wheel>=0.34.2                   # Wheel | ||||
| Django==2.2.10                  # Django package | ||||
| Django==3.0.5                   # Django package | ||||
| pillow==6.2.0                   # Image manipulation | ||||
| djangorestframework==3.10.3     # DRF framework | ||||
| django-dbbackup==3.3.0          # Database backup / restore functionality | ||||
| django-cors-headers==3.2.0      # CORS headers extension for DRF | ||||
| django_filter==2.2.0            # Extended filtering options | ||||
| django-mptt==0.10.0             # Modified Preorder Tree Traversal | ||||
| django-dbbackup==3.2.0          # Database backup / restore functionality | ||||
| django-mptt==0.11.0             # Modified Preorder Tree Traversal | ||||
| django-markdownx==3.0.1         # Markdown form fields | ||||
| django-markdownify==0.8.0       # Markdown rendering | ||||
| coreapi==2.3.0                  # API documentation | ||||
| @@ -14,9 +14,12 @@ tablib==0.13.0                  # Import / export data files | ||||
| django-crispy-forms==1.8.1      # Form helpers | ||||
| django-import-export==2.0.0     # Data import / export for admin interface | ||||
| django-cleanup==4.0.0           # Manage deletion of old / unused uploaded files | ||||
| django-qr-code==1.1.0           # Generate QR codes | ||||
| # TODO: Once the official django-qr-code package has been updated with Django3.x support, | ||||
| # the following line should be removed. | ||||
| git+git://github.com/chrissam/django-qr-code | ||||
| # django-qr-code==1.1.0           # Generate QR codes | ||||
| flake8==3.3.0                   # PEP checking | ||||
| coverage==4.0.3                 # Unit test coverage | ||||
| python-coveralls==2.9.1         # Coveralls linking (for Travis) | ||||
| rapidfuzz==0.2.1                # Fuzzy string matching | ||||
| django-stdimage==5.0.3          # Advanced ImageField management | ||||
| rapidfuzz==0.7.6                # Fuzzy string matching | ||||
| django-stdimage==5.1.1          # Advanced ImageField management | ||||
		Reference in New Issue
	
	Block a user