mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
c49cd9ffde
@ -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 += ' | ';
|
|
||||||
}
|
}
|
||||||
|
else if (field == 'part_description') {
|
||||||
name += row.part__name;
|
return row.part_detail.description;
|
||||||
|
|
||||||
return imageHoverIcon(row.part__thumbnail) + name + ' <i>(' + data.length + ' items)</i>';
|
|
||||||
}
|
|
||||||
else if (field == 'part__description') {
|
|
||||||
return row.part__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
|
Loading…
Reference in New Issue
Block a user