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 |      *      disableFilters: If true, disable custom filters | ||||||
|      */ |      */ | ||||||
|  |  | ||||||
|  |     // Ensure category detail is included | ||||||
|  |     options.params['category_detail'] = true; | ||||||
|  |  | ||||||
|     var params = options.params || {}; |     var params = options.params || {}; | ||||||
|  |  | ||||||
|     var filters = {}; |     var filters = {}; | ||||||
| @@ -184,11 +187,11 @@ function loadPartTable(table, url, options={}) { | |||||||
|      |      | ||||||
|     columns.push({ |     columns.push({ | ||||||
|         sortable: true, |         sortable: true, | ||||||
|         field: 'category__name', |         field: 'category_detail', | ||||||
|         title: 'Category', |         title: 'Category', | ||||||
|         formatter: function(value, row, index, field) { |         formatter: function(value, row, index, field) { | ||||||
|             if (row.category) { |             if (row.category) { | ||||||
|                 return renderLink(row.category__name, "/part/category/" + row.category + "/"); |                 return renderLink(value.pathstring, "/part/category/" + row.category + "/"); | ||||||
|             } |             } | ||||||
|             else { |             else { | ||||||
|                 return 'No category'; |                 return 'No category'; | ||||||
|   | |||||||
| @@ -45,6 +45,10 @@ function loadStockTable(table, options) { | |||||||
|      */ |      */ | ||||||
|  |  | ||||||
|     // List of user-params which override the default filters |     // List of user-params which override the default filters | ||||||
|  |  | ||||||
|  |     options.params['part_detail'] = true; | ||||||
|  |     options.params['location_detail'] = true; | ||||||
|  |  | ||||||
|     var params = options.params || {}; |     var params = options.params || {}; | ||||||
|  |  | ||||||
|     var filterListElement = options.filterList || "#filter-list-stock"; |     var filterListElement = options.filterList || "#filter-list-stock"; | ||||||
| @@ -83,27 +87,21 @@ function loadStockTable(table, options) { | |||||||
|  |  | ||||||
|             var row = data[0]; |             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) { |                 return imageHoverIcon(row.part_detail.thumbnail) + name + ' <i>(' + data.length + ' items)</i>'; | ||||||
|                     name += ' | '; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 name += row.part__name; |  | ||||||
|  |  | ||||||
|                 return imageHoverIcon(row.part__thumbnail) + name + ' <i>(' + data.length + ' items)</i>'; |  | ||||||
|             } |             } | ||||||
|             else if (field == 'part__description') { |             else if (field == 'part_description') { | ||||||
|                 return row.part__description; |                 return row.part_detail.description; | ||||||
|             } |             } | ||||||
|             else if (field == 'quantity') { |             else if (field == 'quantity') { | ||||||
|                 var stock = 0; |                 var stock = 0; | ||||||
|                 var items = 0; |                 var items = 0; | ||||||
|  |  | ||||||
|                 data.forEach(function(item) { |                 data.forEach(function(item) { | ||||||
|                     stock += item.quantity;  |                     stock += parseFloat(item.quantity);  | ||||||
|                     items += 1; |                     items += 1; | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
| @@ -216,25 +214,14 @@ function loadStockTable(table, options) { | |||||||
|                 visible: false, |                 visible: false, | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|                 field: 'part__name', |                 field: 'part_name', | ||||||
|                 title: 'Part', |                 title: 'Part', | ||||||
|                 sortable: true, |                 sortable: true, | ||||||
|                 formatter: function(value, row, index, field) { |                 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 url = ''; | ||||||
|  |                     var thumb = row.part_detail.thumbnail; | ||||||
|  |                     var name = row.part_detail.full_name; | ||||||
|  |  | ||||||
|                     if (row.supplier_part) { |                     if (row.supplier_part) { | ||||||
|                         url = `/supplier-part/${row.supplier_part}/`; |                         url = `/supplier-part/${row.supplier_part}/`; | ||||||
| @@ -242,13 +229,16 @@ function loadStockTable(table, options) { | |||||||
|                         url = `/part/${row.part}/`; |                         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', |                 title: 'Description', | ||||||
|                 sortable: true, |                 sortable: true, | ||||||
|  |                 formatter: function(value, row, index, field) { | ||||||
|  |                     return row.part_detail.description; | ||||||
|  |                 } | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|                 field: 'quantity', |                 field: 'quantity', | ||||||
| @@ -256,11 +246,13 @@ function loadStockTable(table, options) { | |||||||
|                 sortable: true, |                 sortable: true, | ||||||
|                 formatter: function(value, row, index, field) { |                 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 there is a single unit with a serial number, use the serial number | ||||||
|                     if (row.serial && row.quantity == 1) { |                     if (row.serial && row.quantity == 1) { | ||||||
|                         val = '# ' + row.serial; |                         val = '# ' + row.serial; | ||||||
|  |                     } else { | ||||||
|  |                         val = +val.toFixed(5); | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                     var text = renderLink(val, '/stock/item/' + row.pk + '/'); |                     var text = renderLink(val, '/stock/item/' + row.pk + '/'); | ||||||
| @@ -282,7 +274,7 @@ function loadStockTable(table, options) { | |||||||
|                 sortable: true, |                 sortable: true, | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|                 field: 'location__path', |                 field: 'location_detail.pathstring', | ||||||
|                 title: 'Location', |                 title: 'Location', | ||||||
|                 sortable: true, |                 sortable: true, | ||||||
|                 formatter: function(value, row, index, field) { |                 formatter: function(value, row, index, field) { | ||||||
|   | |||||||
| @@ -4,8 +4,9 @@ Provides information on the current InvenTree version | |||||||
|  |  | ||||||
| import subprocess | import subprocess | ||||||
| from common.models import InvenTreeSetting | from common.models import InvenTreeSetting | ||||||
|  | import django | ||||||
|  |  | ||||||
| INVENTREE_SW_VERSION = "0.0.11_pre" | INVENTREE_SW_VERSION = "0.0.12 pre" | ||||||
|  |  | ||||||
|  |  | ||||||
| def inventreeInstanceName(): | def inventreeInstanceName(): | ||||||
| @@ -18,6 +19,11 @@ def inventreeVersion(): | |||||||
|     return INVENTREE_SW_VERSION |     return INVENTREE_SW_VERSION | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def inventreeDjangoVersion(): | ||||||
|  |     """ Return the version of Django library """ | ||||||
|  |     return django.get_version() | ||||||
|  |  | ||||||
|  |  | ||||||
| def inventreeCommitHash(): | def inventreeCommitHash(): | ||||||
|     """ Returns the git commit hash for the running codebase """ |     """ 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 __future__ import unicode_literals | ||||||
|  |  | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | 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 import Q, F, Count | ||||||
| from django.db.models.functions import Coalesce |  | ||||||
|  |  | ||||||
| from rest_framework import status | from rest_framework import status | ||||||
| from rest_framework.response import Response | 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.conf.urls import url, include | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
|  |  | ||||||
| import os |  | ||||||
| from decimal import Decimal |  | ||||||
|  |  | ||||||
| from .models import Part, PartCategory, BomItem, PartStar | from .models import Part, PartCategory, BomItem, PartStar | ||||||
| from .models import PartParameter, PartParameterTemplate | from .models import PartParameter, PartParameterTemplate | ||||||
|  |  | ||||||
| from . import serializers as part_serializers | from . import serializers as part_serializers | ||||||
|  |  | ||||||
| from InvenTree.status_codes import OrderStatus, StockStatus, BuildStatus |  | ||||||
| from InvenTree.views import TreeSerializer | from InvenTree.views import TreeSerializer | ||||||
| from InvenTree.helpers import str2bool, isNull | from InvenTree.helpers import str2bool, isNull | ||||||
|  |  | ||||||
| @@ -125,6 +119,8 @@ class PartThumbs(generics.ListAPIView): | |||||||
|         # Get all Parts which have an associated image |         # Get all Parts which have an associated image | ||||||
|         queryset = Part.objects.all().exclude(image='') |         queryset = Part.objects.all().exclude(image='') | ||||||
|  |  | ||||||
|  |         # TODO - We should return the thumbnails here, not the full image! | ||||||
|  |  | ||||||
|         # Return the most popular parts first |         # Return the most popular parts first | ||||||
|         data = queryset.values( |         data = queryset.values( | ||||||
|             'image', |             'image', | ||||||
| @@ -166,6 +162,31 @@ class PartList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|     serializer_class = part_serializers.PartSerializer |     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): |     def create(self, request, *args, **kwargs): | ||||||
|         """ Override the default 'create' behaviour: |         """ Override the default 'create' behaviour: | ||||||
|         We wish to save the user who created this part! |         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) |         headers = self.get_success_headers(serializer.data) | ||||||
|         return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) |         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, |         Perform custom filtering of the queryset | ||||||
|         we serialize the objects manually. |  | ||||||
|         This turns out to be significantly faster. |  | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         queryset = self.filter_queryset(self.get_queryset()) |         # Perform basic filtering | ||||||
|  |         queryset = super().filter_queryset(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() |  | ||||||
|  |  | ||||||
|         # Filter by 'starred' parts? |         # Filter by 'starred' parts? | ||||||
|         starred = str2bool(self.request.query_params.get('starred', None)) |         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()] |             starred_parts = [star.part.pk for star in self.request.user.starred_parts.all()] | ||||||
|  |  | ||||||
|             if starred: |             if starred: | ||||||
|                 parts_list = parts_list.filter(pk__in=starred_parts) |                 queryset = queryset.filter(pk__in=starred_parts) | ||||||
|             else: |             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)) |         cascade = str2bool(self.request.query_params.get('cascade', None)) | ||||||
|  |  | ||||||
|         # Does the user wish to filter by category? |         # Does the user wish to filter by category? | ||||||
| @@ -334,7 +247,7 @@ class PartList(generics.ListCreateAPIView): | |||||||
|                 # A 'null' category is the top-level category |                 # A 'null' category is the top-level category | ||||||
|                 if cascade is False: |                 if cascade is False: | ||||||
|                     # Do not cascade, only list parts in the top-level category |                     # Do not cascade, only list parts in the top-level category | ||||||
|                     parts_list = parts_list.filter(category=None) |                     queryset = queryset.filter(category=None) | ||||||
|  |  | ||||||
|             else: |             else: | ||||||
|                 try: |                 try: | ||||||
| @@ -342,17 +255,43 @@ class PartList(generics.ListCreateAPIView): | |||||||
|  |  | ||||||
|                     # If '?cascade=true' then include parts which exist in sub-categories |                     # If '?cascade=true' then include parts which exist in sub-categories | ||||||
|                     if cascade: |                     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 |                     # Just return parts directly in the requested category | ||||||
|                     else: |                     else: | ||||||
|                         parts_list = parts_list.filter(category=cat_id) |                         queryset = queryset.filter(category=cat_id) | ||||||
|                 except (ValueError, PartCategory.DoesNotExist): |                 except (ValueError, PartCategory.DoesNotExist): | ||||||
|                     pass |                     pass | ||||||
|  |  | ||||||
|         # Ensure that related models are pre-loaded to reduce DB trips |         # Annotate calculated data to the queryset | ||||||
|         parts_list = self.get_serializer_class().setup_eager_loading(parts_list) |         # (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 = [ |     permission_classes = [ | ||||||
|         permissions.IsAuthenticated, |         permissions.IsAuthenticated, | ||||||
| @@ -379,6 +318,7 @@ class PartList(generics.ListCreateAPIView): | |||||||
|         'name', |         'name', | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |     # Default ordering | ||||||
|     ordering = 'name' |     ordering = 'name' | ||||||
|  |  | ||||||
|     search_fields = [ |     search_fields = [ | ||||||
| @@ -507,7 +447,9 @@ class BomList(generics.ListCreateAPIView): | |||||||
|         kwargs['part_detail'] = part_detail |         kwargs['part_detail'] = part_detail | ||||||
|         kwargs['sub_part_detail'] = sub_part_detail |         kwargs['sub_part_detail'] = sub_part_detail | ||||||
|  |  | ||||||
|  |         # Ensure the request context is passed through! | ||||||
|         kwargs['context'] = self.get_serializer_context() |         kwargs['context'] = self.get_serializer_context() | ||||||
|  |          | ||||||
|         return self.serializer_class(*args, **kwargs) |         return self.serializer_class(*args, **kwargs) | ||||||
|  |  | ||||||
|     def get_queryset(self): |     def get_queryset(self): | ||||||
|   | |||||||
| @@ -10,6 +10,12 @@ from .models import PartCategory | |||||||
| from .models import BomItem | from .models import BomItem | ||||||
| from .models import PartParameter, PartParameterTemplate | 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 | from InvenTree.serializers import InvenTreeModelSerializer | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -49,14 +55,6 @@ class PartBriefSerializer(InvenTreeModelSerializer): | |||||||
|     url = serializers.CharField(source='get_absolute_url', read_only=True) |     url = serializers.CharField(source='get_absolute_url', read_only=True) | ||||||
|     thumbnail = serializers.CharField(source='get_thumbnail_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: |     class Meta: | ||||||
|         model = Part |         model = Part | ||||||
|         fields = [ |         fields = [ | ||||||
| @@ -64,8 +62,6 @@ class PartBriefSerializer(InvenTreeModelSerializer): | |||||||
|             'url', |             'url', | ||||||
|             'full_name', |             'full_name', | ||||||
|             'description', |             'description', | ||||||
|             'total_stock', |  | ||||||
|             'available_stock', |  | ||||||
|             'thumbnail', |             'thumbnail', | ||||||
|             'active', |             'active', | ||||||
|             'assembly', |             'assembly', | ||||||
| @@ -78,57 +74,140 @@ class PartSerializer(InvenTreeModelSerializer): | |||||||
|     Used when displaying all details of a single component. |     Used when displaying all details of a single component. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     allocated_stock = serializers.FloatField(source='allocation_count', read_only=True) |     def __init__(self, *args, **kwargs): | ||||||
|     bom_items = serializers.IntegerField(source='bom_count', read_only=True) |         """ | ||||||
|     building = serializers.FloatField(source='quantity_being_built', read_only=False) |         Custom initialization method for PartSerializer, | ||||||
|     category_name = serializers.CharField(source='category_path', read_only=True) |         so that we can optionally pass extra fields based on the query. | ||||||
|     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) |         self.starred_parts = kwargs.pop('starred_parts', []) | ||||||
|     url = serializers.CharField(source='get_absolute_url', read_only=True) |  | ||||||
|     used_in = serializers.IntegerField(source='used_in_count', read_only=True) |         category_detail = kwargs.pop('category_detail', False) | ||||||
|  |  | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |  | ||||||
|  |         if category_detail is not True: | ||||||
|  |             self.fields.pop('category_detail') | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def setup_eager_loading(queryset): |     def prefetch_queryset(queryset): | ||||||
|         queryset = queryset.prefetch_related('category') |         """ | ||||||
|         queryset = queryset.prefetch_related('stock_items') |         Prefetch related database tables, | ||||||
|         queryset = queryset.prefetch_related('bom_items') |         to reduce database hits. | ||||||
|         queryset = queryset.prefetch_related('builds') |         """ | ||||||
|  |  | ||||||
|  |         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 |         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: |     class Meta: | ||||||
|         model = Part |         model = Part | ||||||
|         partial = True |         partial = True | ||||||
|         fields = [ |         fields = [ | ||||||
|             'active', |             'active', | ||||||
|             'allocated_stock', |             # 'allocated_stock', | ||||||
|             'assembly', |             'assembly', | ||||||
|             'bom_items', |             # 'bom_items', | ||||||
|             'building', |  | ||||||
|             'category', |             'category', | ||||||
|             'category_name', |             'category_detail', | ||||||
|             'component', |             'component', | ||||||
|             'description', |             'description', | ||||||
|             'full_name', |             'full_name', | ||||||
|             'image', |             'image', | ||||||
|  |             'in_stock', | ||||||
|  |             'ordering', | ||||||
|  |             'building', | ||||||
|             'IPN', |             'IPN', | ||||||
|             'is_template', |             'is_template', | ||||||
|             'keywords', |             'keywords', | ||||||
|             'link', |             'link', | ||||||
|  |             'minimum_stock', | ||||||
|             'name', |             'name', | ||||||
|             'notes', |             'notes', | ||||||
|             'on_order', |  | ||||||
|             'pk', |             'pk', | ||||||
|             'purchaseable', |             'purchaseable', | ||||||
|  |             'revision', | ||||||
|             'salable', |             'salable', | ||||||
|  |             'starred', | ||||||
|             'thumbnail', |             'thumbnail', | ||||||
|             'trackable', |             'trackable', | ||||||
|             'total_stock', |  | ||||||
|             'units', |             'units', | ||||||
|             'used_in', |             # 'used_in', | ||||||
|             'url',  # Link to the part detail page |  | ||||||
|             'variant_of', |             'variant_of', | ||||||
|             'virtual', |             'virtual', | ||||||
|         ] |         ] | ||||||
|   | |||||||
| @@ -55,6 +55,12 @@ def inventree_version(*args, **kwargs): | |||||||
|     return version.inventreeVersion() |     return version.inventreeVersion() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @register.simple_tag() | ||||||
|  | def django_version(*args, **kwargs): | ||||||
|  |     """ Return Django version string """ | ||||||
|  |     return version.inventreeDjangoVersion() | ||||||
|  |  | ||||||
|  |  | ||||||
| @register.simple_tag() | @register.simple_tag() | ||||||
| def inventree_commit_hash(*args, **kwargs): | def inventree_commit_hash(*args, **kwargs): | ||||||
|     """ Return InvenTree git commit hash string """ |     """ 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.rest_framework import FilterSet, DjangoFilterBackend | ||||||
| from django_filters import NumberFilter | from django_filters import NumberFilter | ||||||
|  |  | ||||||
| from django.conf import settings |  | ||||||
| from django.conf.urls import url, include | from django.conf.urls import url, include | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.db.models import Q | from django.db.models import Q | ||||||
| @@ -21,9 +20,7 @@ from .serializers import StockTrackingSerializer | |||||||
|  |  | ||||||
| from InvenTree.views import TreeSerializer | from InvenTree.views import TreeSerializer | ||||||
| from InvenTree.helpers import str2bool, isNull | from InvenTree.helpers import str2bool, isNull | ||||||
| from InvenTree.status_codes import StockStatus |  | ||||||
|  |  | ||||||
| import os |  | ||||||
| from decimal import Decimal, InvalidOperation | from decimal import Decimal, InvalidOperation | ||||||
|  |  | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| @@ -317,13 +314,15 @@ class StockList(generics.ListCreateAPIView): | |||||||
|         - status: Filter by the StockItem status |         - status: Filter by the StockItem status | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     serializer_class = StockItemSerializer | ||||||
|  |  | ||||||
|     queryset = StockItem.objects.all() |     queryset = StockItem.objects.all() | ||||||
|  |  | ||||||
|     def get_serializer(self, *args, **kwargs): |     def get_serializer(self, *args, **kwargs): | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             part_detail = str2bool(self.request.GET.get('part_detail', None)) |             part_detail = str2bool(self.request.query_params.get('part_detail', None)) | ||||||
|             location_detail = str2bool(self.request.GET.get('location_detail', None)) |             location_detail = str2bool(self.request.query_params.get('location_detail', None)) | ||||||
|         except AttributeError: |         except AttributeError: | ||||||
|             part_detail = None |             part_detail = None | ||||||
|             location_detail = None |             location_detail = None | ||||||
| @@ -331,86 +330,25 @@ class StockList(generics.ListCreateAPIView): | |||||||
|         kwargs['part_detail'] = part_detail |         kwargs['part_detail'] = part_detail | ||||||
|         kwargs['location_detail'] = location_detail |         kwargs['location_detail'] = location_detail | ||||||
|          |          | ||||||
|  |         # Ensure the request context is passed through | ||||||
|         kwargs['context'] = self.get_serializer_context() |         kwargs['context'] = self.get_serializer_context() | ||||||
|  |  | ||||||
|         return self.serializer_class(*args, **kwargs) |         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, |         queryset = super().get_queryset(*args, **kwargs) | ||||||
|         # we will serialize the objects manually. |         queryset = StockItemSerializer.prefetch_queryset(queryset) | ||||||
|         # This is significantly faster |  | ||||||
|  |  | ||||||
|         data = queryset.values( |         return queryset | ||||||
|             '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', |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         # Reduce the number of lookups we need to do for categories |     def filter_queryset(self, queryset): | ||||||
|         # 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. |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         # Start with all objects |         # 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" |         # Filter out parts which are not actually "in stock" | ||||||
|         stock_list = stock_list.filter(customer=None, belongs_to=None) |         stock_list = stock_list.filter(customer=None, belongs_to=None) | ||||||
|   | |||||||
| @@ -8,7 +8,6 @@ from .models import StockItem, StockLocation | |||||||
| from .models import StockItemTracking | from .models import StockItemTracking | ||||||
|  |  | ||||||
| from part.serializers import PartBriefSerializer | from part.serializers import PartBriefSerializer | ||||||
| from company.serializers import SupplierPartSerializer |  | ||||||
| from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer | from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -56,24 +55,43 @@ class StockItemSerializer(InvenTreeModelSerializer): | |||||||
|     - Includes serialization for the item location |     - 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) |     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) |     part_detail = PartBriefSerializer(source='part', many=False, read_only=True) | ||||||
|     location_detail = LocationBriefSerializer(source='location', 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): |     def __init__(self, *args, **kwargs): | ||||||
|  |  | ||||||
|         part_detail = kwargs.pop('part_detail', False) |         part_detail = kwargs.pop('part_detail', False) | ||||||
|         location_detail = kwargs.pop('location_detail', False) |         location_detail = kwargs.pop('location_detail', False) | ||||||
|         supplier_detail = kwargs.pop('supplier_detail', False) |  | ||||||
|  |  | ||||||
|         super(StockItemSerializer, self).__init__(*args, **kwargs) |         super(StockItemSerializer, self).__init__(*args, **kwargs) | ||||||
|  |  | ||||||
| @@ -83,9 +101,6 @@ class StockItemSerializer(InvenTreeModelSerializer): | |||||||
|         if location_detail is not True: |         if location_detail is not True: | ||||||
|             self.fields.pop('location_detail') |             self.fields.pop('location_detail') | ||||||
|  |  | ||||||
|         if supplier_detail is not True: |  | ||||||
|             self.fields.pop('supplier_detail') |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = StockItem |         model = StockItem | ||||||
|         fields = [ |         fields = [ | ||||||
| @@ -97,18 +112,14 @@ class StockItemSerializer(InvenTreeModelSerializer): | |||||||
|             'notes', |             'notes', | ||||||
|             'part', |             'part', | ||||||
|             'part_detail', |             'part_detail', | ||||||
|             'part_name', |  | ||||||
|             'part_image', |  | ||||||
|             'pk', |             'pk', | ||||||
|             'quantity', |             'quantity', | ||||||
|             'serial', |             'serial', | ||||||
|             'supplier_part', |             'supplier_part', | ||||||
|             'supplier_detail', |  | ||||||
|             'status', |             'status', | ||||||
|             'status_text', |             'status_text', | ||||||
|             'tracking_items', |             'tracking_items', | ||||||
|             'uid', |             'uid', | ||||||
|             'url', |  | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|         """ These fields are read-only in this context. |         """ These fields are read-only in this context. | ||||||
|   | |||||||
| @@ -25,6 +25,10 @@ | |||||||
|                             <td><span class='fas fa-hashtag'></span></td> |                             <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> |                             <td>{% trans "InvenTree Version" %}</td><td><a href="https://github.com/inventree/InvenTree/releases">{% inventree_version %}</a></td> | ||||||
|                         </tr> |                         </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> |                         <tr> | ||||||
|                             <td><span class='fas fa-code-branch'></span></td> |                             <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> |                             <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> |                         <tr> | ||||||
|                             <td><span class='fas fa-exclamation-circle'></span></td> |                             <td><span class='fas fa-exclamation-circle'></span></td> | ||||||
|                             <td>{% trans "Submit Bug Report" %}</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> |                         </tr> | ||||||
|                     </table> |                     </table> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| wheel>=0.34.2                   # Wheel | wheel>=0.34.2                   # Wheel | ||||||
| Django==2.2.10                  # Django package | Django==3.0.5                   # Django package | ||||||
| pillow==6.2.0                   # Image manipulation | pillow==6.2.0                   # Image manipulation | ||||||
| djangorestframework==3.10.3     # DRF framework | 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-cors-headers==3.2.0      # CORS headers extension for DRF | ||||||
| django_filter==2.2.0            # Extended filtering options | django_filter==2.2.0            # Extended filtering options | ||||||
| django-mptt==0.10.0             # Modified Preorder Tree Traversal | django-mptt==0.11.0             # Modified Preorder Tree Traversal | ||||||
| django-dbbackup==3.2.0          # Database backup / restore functionality |  | ||||||
| django-markdownx==3.0.1         # Markdown form fields | django-markdownx==3.0.1         # Markdown form fields | ||||||
| django-markdownify==0.8.0       # Markdown rendering | django-markdownify==0.8.0       # Markdown rendering | ||||||
| coreapi==2.3.0                  # API documentation | 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-crispy-forms==1.8.1      # Form helpers | ||||||
| django-import-export==2.0.0     # Data import / export for admin interface | 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-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 | flake8==3.3.0                   # PEP checking | ||||||
| coverage==4.0.3                 # Unit test coverage | coverage==4.0.3                 # Unit test coverage | ||||||
| python-coveralls==2.9.1         # Coveralls linking (for Travis) | python-coveralls==2.9.1         # Coveralls linking (for Travis) | ||||||
| rapidfuzz==0.2.1                # Fuzzy string matching | rapidfuzz==0.7.6                # Fuzzy string matching | ||||||
| django-stdimage==5.0.3          # Advanced ImageField management | django-stdimage==5.1.1          # Advanced ImageField management | ||||||
		Reference in New Issue
	
	Block a user