Merge pull request #375 from SchrodingersGat/group-stock-items

Group stock items
This commit is contained in:
Oliver 2019-05-29 01:05:57 +10:00 committed by GitHub
commit da4f68e5a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 4586 additions and 44 deletions

View File

@ -104,7 +104,6 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'InvenTree.middleware.AuthRequiredMiddleware',
'InvenTree.middleware.QueryCountMiddleware',
]
if DEBUG:

View File

@ -46,6 +46,7 @@
location_detail: true,
part_detail: true,
},
groupByField: 'location',
buttons: [
'#stock-options',
],

View File

@ -0,0 +1,11 @@
.bootstrap-table .table > tbody > tr.groupBy {
cursor: pointer;
}
.bootstrap-table .table > tbody > tr.groupBy.expanded {
}
.bootstrap-table .table > tbody > tr.hidden + tr.detail-view {
display: none;
}

File diff suppressed because one or more lines are too long

View File

@ -43,6 +43,12 @@
font-size: 100%;
}
/* Bootstrap table overrides */
.stock-sub-group td {
background-color: #ebf4f4;
}
/* Force select2 elements in modal forms to be full width */
.select-full-width {
width: 100%;

View File

@ -0,0 +1,269 @@
(function (global, factory) {
if (typeof define === "function" && define.amd) {
define([], factory);
} else if (typeof exports !== "undefined") {
factory();
} else {
var mod = {
exports: {}
};
factory();
global.bootstrapTableGroupBy = mod.exports;
}
})(this, function () {
'use strict';
/**
* @author: Yura Knoxville
* @version: v1.1.0
*/
(function ($) {
'use strict';
var initBodyCaller, tableGroups;
// it only does '%s', and return '' when arguments are undefined
var sprintf = function sprintf(str) {
var args = arguments,
flag = true,
i = 1;
str = str.replace(/%s/g, function () {
var arg = args[i++];
if (typeof arg === 'undefined') {
flag = false;
return '';
}
return arg;
});
return flag ? str : '';
};
var groupBy = function groupBy(array, f) {
var groups = {};
array.forEach(function (o) {
var group = f(o);
groups[group] = groups[group] || [];
groups[group].push(o);
});
return groups;
};
$.extend($.fn.bootstrapTable.defaults, {
groupBy: false,
groupByField: '',
groupByFormatter: undefined
});
var BootstrapTable = $.fn.bootstrapTable.Constructor,
_initSort = BootstrapTable.prototype.initSort,
_initBody = BootstrapTable.prototype.initBody,
_updateSelected = BootstrapTable.prototype.updateSelected;
function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
BootstrapTable.prototype.initSort = function () {
_initSort.apply(this, Array.prototype.slice.apply(arguments));
var that = this;
tableGroups = [];
/* Sort the items into groups */
if (this.options.groupBy && this.options.groupByField !== '') {
var that = this;
var groups = groupBy(that.data, function (item) {
return [item[that.options.groupByField]];
});
var index = 0;
$.each(groups, function (key, value) {
tableGroups.push({
id: index,
name: key,
data: value
});
value.forEach(function (item) {
if (!item._data) {
item._data = {};
}
if (value.length > 1) {
item._data['parent-index'] = index;
} else {
item._data['parent-index'] = null;
}
item._data['group-data'] = value;
item._data['table'] = that;
});
index++;
});
}
};
BootstrapTable.prototype.initBody = function () {
initBodyCaller = true;
_initBody.apply(this, Array.prototype.slice.apply(arguments));
if (this.options.groupBy && this.options.groupByField !== '') {
var that = this,
checkBox = false,
visibleColumns = 0;
var cols = [];
this.columns.forEach(function (column) {
if (column.checkbox) {
checkBox = true;
} else {
if (column.visible) {
visibleColumns += 1;
cols.push(column);
}
}
});
if (this.options.detailView && !this.options.cardView) {
visibleColumns += 1;
}
tableGroups.forEach(function (item) {
var html = [];
html.push(sprintf('<tr class="info groupBy expanded" data-group-index="%s">', item.id));
if (that.options.detailView && !that.options.cardView) {
html.push('<td class="detail"></td>');
}
if (checkBox) {
html.push('<td class="bs-checkbox">', '<input name="btSelectGroup" type="checkbox" />', '</td>');
}
cols.forEach(function(col) {
var cell = '<td>';
if (typeof that.options.groupByFormatter == 'function') {
cell += '<i>' + that.options.groupByFormatter(col.field, item.id, item.data) + "</i>";
}
cell += "</td>";
html.push(cell);
});
/*
var formattedValue = item.name;
if (typeof that.options.groupByFormatter == "function") {
formattedValue = that.options.groupByFormatter(item.name, item.id, item.data);
}
html.push('<td', sprintf(' colspan="%s"', visibleColumns), '>', formattedValue, '</td>');
cols.forEach(function(col) {
html.push('<td>' + item.data[0][col.field] + '</td>');
});
*/
html.push('</tr>');
if(item.data.length > 1) {
that.$body.find('tr[data-parent-index=' + item.id + ']').addClass('hidden stock-sub-group');
// Insert the group header row before the first item
that.$body.find('tr[data-parent-index=' + item.id + ']:first').before($(html.join('')));
}
});
this.$selectGroup = [];
this.$body.find('[name="btSelectGroup"]').each(function () {
var self = $(this);
that.$selectGroup.push({
group: self,
item: that.$selectItem.filter(function () {
return $(this).closest('tr').data('parent-index') === self.closest('tr').data('group-index');
})
});
});
this.$container.off('click', '.groupBy').on('click', '.groupBy', function () {
$(this).toggleClass('expanded');
that.$body.find('tr[data-parent-index=' + $(this).closest('tr').data('group-index') + ']').toggleClass('hidden');
});
this.$container.off('click', '[name="btSelectGroup"]').on('click', '[name="btSelectGroup"]', function (event) {
event.stopImmediatePropagation();
var self = $(this);
var checked = self.prop('checked');
that[checked ? 'checkGroup' : 'uncheckGroup']($(this).closest('tr').data('group-index'));
});
}
initBodyCaller = false;
this.updateSelected();
};
BootstrapTable.prototype.updateSelected = function () {
if (!initBodyCaller) {
_updateSelected.apply(this, Array.prototype.slice.apply(arguments));
if (this.options.groupBy && this.options.groupByField !== '') {
this.$selectGroup.forEach(function (item) {
var checkGroup = item.item.filter(':enabled').length === item.item.filter(':enabled').filter(':checked').length;
item.group.prop('checked', checkGroup);
});
}
}
};
BootstrapTable.prototype.getGroupSelections = function (index) {
var that = this;
return $.grep(this.data, function (row) {
return row[that.header.stateField] && row._data['parent-index'] === index;
});
};
BootstrapTable.prototype.checkGroup = function (index) {
this.checkGroup_(index, true);
};
BootstrapTable.prototype.uncheckGroup = function (index) {
this.checkGroup_(index, false);
};
BootstrapTable.prototype.checkGroup_ = function (index, checked) {
var rows;
var filter = function filter() {
return $(this).closest('tr').data('parent-index') === index;
};
if (!checked) {
rows = this.getGroupSelections(index);
}
this.$selectItem.filter(filter).prop('checked', checked);
this.updateRows();
this.updateSelected();
if (checked) {
rows = this.getGroupSelections(index);
}
this.trigger(checked ? 'check-all' : 'uncheck-all', rows);
};
})(jQuery);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -282,7 +282,7 @@ function moveStockItems(items, options) {
for (i = 0; i < response.length; i++) {
var loc = response[i];
html += makeOption(loc.pk, loc.name + ' - <i>' + loc.description + '</i>');
html += makeOption(loc.pk, loc.pathstring + ' - <i>' + loc.description + '</i>');
}
html += "</select><br>";
@ -312,10 +312,18 @@ function moveStockItems(items, options) {
var item = items[i];
var name = item.part__IPN;
if (name) {
name += ' | ';
}
name += item.part__name;
html += "<tr>";
html += "<td>" + item.part.full_name + "</td>";
html += "<td>" + item.location.pathstring + "</td>";
html += "<td>" + name + "</td>";
html += "<td>" + item.location__path + "</td>";
html += "<td>" + item.quantity + "</td>";
html += "<td>";
@ -372,14 +380,74 @@ function moveStockItems(items, options) {
function loadStockTable(table, options) {
var params = options.params || {};
// Aggregate stock items
//params.aggregate = true;
table.bootstrapTable({
sortable: true,
search: true,
method: 'get',
pagination: true,
pageSize: 50,
pageSize: 25,
rememberOrder: true,
queryParams: options.params,
formatNoMatches: function() {
return 'No stock items matching query';
},
customSort: customGroupSorter,
groupBy: true,
groupByField: options.groupByField || 'part',
groupByFormatter: function(field, id, data) {
var row = data[0];
if (field == 'part__name') {
var name = row.part__IPN;
if (name) {
name += ' | ';
}
name += row.part__name;
return imageHoverIcon(row.part__image) + name + ' <i>(' + data.length + ' items)</i>';
}
else if (field == 'part__description') {
return row.part__description;
}
else if (field == 'quantity') {
var stock = 0;
data.forEach(function(item) {
stock += item.quantity;
});
return stock;
} else if (field == 'location__path') {
/* Determine how many locations */
var locations = [];
data.forEach(function(item) {
var loc = item.location;
if (!locations.includes(loc)) {
locations.push(loc);
}
});
if (locations.length > 1) {
return "In " + locations.length + " locations";
} else {
// A single location!
return renderLink(row.location__path, '/stock/location/' + row.location + '/')
}
}
else {
return '';
}
},
columns: [
{
checkbox: true,
@ -392,47 +460,66 @@ function loadStockTable(table, options) {
visible: false,
},
{
field: 'part_detail',
field: 'part__name',
title: 'Part',
sortable: true,
formatter: function(value, row, index, field) {
return imageHoverIcon(value.image_url) + renderLink(value.full_name, value.url + 'stock/');
var name = row.part__IPN;
if (name) {
name += ' | ';
}
name += row.part__name;
return imageHoverIcon(row.part__image) + renderLink(name, '/part/' + row.part + '/stock/');
}
},
{
field: 'part_detail.description',
field: 'part__description',
title: 'Description',
sortable: true,
},
{
field: 'location_detail',
field: 'quantity',
title: 'Stock',
sortable: true,
formatter: function(value, row, index, field) {
var val = value;
// If there is a single unit with a serial number, use the serial number
if (row.serial && row.quantity == 1) {
val = '# ' + row.serial;
}
var text = renderLink(val, '/stock/item/' + row.pk + '/');
text = text + "<span class='badge'>" + row.status_text + "</span>";
return text;
}
},
{
field: 'location__path',
title: 'Location',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return renderLink(value.pathstring, value.url);
return renderLink(value, '/stock/location/' + row.location + '/');
}
else {
return '<i>No stock location set</i>';
}
}
},
{
field: 'quantity',
title: 'Quantity',
sortable: true,
formatter: function(value, row, index, field) {
var text = renderLink(value, row.url);
text = text + "<span class='badge'>" + row.status_text + "</span>";
return text;
}
},
{
field: 'notes',
title: 'Notes',
}
],
url: options.url,
queryParams: params,
});
if (options.buttons) {

View File

@ -37,3 +37,74 @@ function linkButtonsToSelection(table, buttons) {
enableButtons(buttons, table.bootstrapTable('getSelections').length > 0);
});
}
function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
function customGroupSorter(sortName, sortOrder, sortData) {
console.log('got here');
var order = sortOrder === 'desc' ? -1 : 1;
sortData.sort(function(a, b) {
// Extract default field values
var aa = a[sortName];
var bb = b[sortName];
// Extract parent information
var aparent = a._data && a._data['parent-index'];
var bparent = b._data && b._data['parent-index'];
// If either of the comparisons are in a group
if (aparent || bparent) {
// If the parents are different (or one item does not have a parent,
// then we need to extract the parent value for the selected column.
if (aparent != bparent) {
if (aparent) {
aa = a._data['table'].options.groupByFormatter(sortName, 0, a._data['group-data']);
}
if (bparent) {
bb = b._data['table'].options.groupByFormatter(sortName, 0, b._data['group-data']);
}
}
}
if (aa === undefined || aa === null) {
aa = '';
}
if (bb === undefined || bb === null) {
bb = '';
}
if (isNumeric(aa) && isNumeric(bb)) {
if (aa < bb) {
return order * -1;
} else if (aa > bb) {
return order;
} else {
return 0;
}
}
aa = aa.toString();
bb = bb.toString();
var cmp = aa.localeCompare(bb);
if (cmp === -1) {
return order * -1;
} else if (cmp === 1) {
return order;
} else {
return 0;
}
});
}

View File

@ -5,6 +5,7 @@ JSON API for the Stock app
from django_filters.rest_framework import FilterSet, DjangoFilterBackend
from django_filters import NumberFilter
from django.conf import settings
from django.conf.urls import url, include
from django.urls import reverse
@ -20,6 +21,8 @@ from .serializers import StockTrackingSerializer
from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool
import os
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from rest_framework.response import Response
@ -241,6 +244,7 @@ class StockList(generics.ListCreateAPIView):
- POST: Create a new StockItem
Additional query parameters are available:
- aggregate: If 'true' then stock items are aggregated by Part and Location
- location: Filter stock by location
- category: Filter by parts belonging to a certain category
- supplier: Filter by supplier
@ -257,6 +261,53 @@ class StockList(generics.ListCreateAPIView):
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
# Instead of using the DRF serializer to LIST,
# we will serialize the objects manually.
# This is significantly faster
data = queryset.values(
'pk',
'quantity',
'serial',
'batch',
'status',
'notes',
'location',
'location__name',
'location__description',
'part',
'part__IPN',
'part__name',
'part__description',
'part__image',
'part__category',
'part__category__name',
'part__category__description',
)
# Reduce the number of lookups we need to do for categories
# Cache location lookups for this query
locations = {}
for item in data:
item['part__image'] = os.path.join(settings.MEDIA_URL, 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
return Response(data)
def get_queryset(self):
"""
If the query includes a particular location,
@ -264,7 +315,7 @@ class StockList(generics.ListCreateAPIView):
"""
# Start with all objects
stock_list = StockItem.objects.all()
stock_list = StockItem.objects.filter(customer=None, belongs_to=None)
# Does the client wish to filter by the Part ID?
part_id = self.request.query_params.get('part', None)
@ -310,8 +361,14 @@ class StockList(generics.ListCreateAPIView):
if supplier_id:
stock_list = stock_list.filter(supplier_part__supplier=supplier_id)
# Pre-fetch related objects for better response time
stock_list = self.get_serializer_class().setup_eager_loading(stock_list)
# Also ensure that we pre-fecth all the related items
stock_list = stock_list.prefetch_related(
'part',
'part__category',
'location'
)
stock_list = stock_list.order_by('part__name')
return stock_list

View File

@ -183,6 +183,9 @@ class StockItem(models.Model):
def get_absolute_url(self):
return reverse('stock-item-detail', kwargs={'pk': self.id})
def get_part_name(self):
return self.part.full_name
class Meta:
unique_together = [
('part', 'serial'),

View File

@ -57,17 +57,10 @@ class StockItemSerializer(serializers.ModelSerializer):
url = serializers.CharField(source='get_absolute_url', 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_detail = PartBriefSerializer(source='part', many=False, read_only=True)
location_detail = LocationBriefSerializer(source='location', many=False, read_only=True)
@staticmethod
def setup_eager_loading(queryset):
queryset = queryset.prefetch_related('part')
queryset = queryset.prefetch_related('part__stock_items')
queryset = queryset.prefetch_related('part__category')
queryset = queryset.prefetch_related('location')
return queryset
def __init__(self, *args, **kwargs):
@ -88,6 +81,7 @@ class StockItemSerializer(serializers.ModelSerializer):
'pk',
'url',
'part',
'part_name',
'part_detail',
'supplier_part',
'location',

View File

@ -30,6 +30,7 @@
<!-- CSS -->
<link rel="stylesheet" href="{% static 'css/bootstrap_3.3.7_css_bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap-table.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap-table-group-by.css' %}">
<link rel="stylesheet" href="{% static 'css/select2.css' %}">
<link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
@ -87,8 +88,9 @@ InvenTree
<script type="text/javascript" src="{% static 'script/bootstrap/bootstrap.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-treeview.js' %}"></script>
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table.js' %}"></script>
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-en-US.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-group-by.js' %}"></script>
<script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>