Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2021-03-02 08:04:18 +11:00
commit a604c828e4
24 changed files with 260 additions and 113 deletions

View File

@ -18,7 +18,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from .views import AjaxView
from .version import inventreeVersion, inventreeInstanceName
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
from plugins import plugins as inventree_plugins
@ -43,11 +43,29 @@ class InfoView(AjaxView):
'server': 'InvenTree',
'version': inventreeVersion(),
'instance': inventreeInstanceName(),
'apiVersion': inventreeApiVersion(),
}
return JsonResponse(data)
class NotFoundView(AjaxView):
"""
Simple JSON view when accessing an invalid API view.
"""
permission_classes = [permissions.AllowAny]
def get(self, request, *args, **kwargs):
data = {
'details': _('API endpoint not found'),
'url': request.build_absolute_uri(),
}
return JsonResponse(data, status=404)
class AttachmentMixin:
"""
Mixin for creating attachment objects,

View File

@ -275,12 +275,13 @@ REST_FRAMEWORK = {
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
'rest_framework.permissions.DjangoModelPermissions',
'InvenTree.permissions.RolePermission',
),
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema'
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
}
WSGI_APPLICATION = 'InvenTree.wsgi.application'

View File

@ -725,6 +725,7 @@ input[type="submit"] {
top: 70px;
position: sticky;
font-size: 115%;
margin-left: 5px;
}
.sidenav-right svg {

View File

@ -67,79 +67,91 @@ function loadTree(url, tree, options={}) {
});
}
function openSideNav(navId) {
// document.getElementById("sidenav").style.display = "block";
// document.getElementById("sidenav").style.width = "250px";
if (!navId) {
navId = '#sidenav-left';
/**
* Initialize navigation tree display
*/
function initNavTree(options) {
var resize = true;
if ('resize' in options) {
resize = options.resize;
}
sessionStorage.setItem('inventree-sidenav-state', 'open');
var label = options.label || 'nav';
$(navId).animate({
width: '250px',
'min-width': '200px',
display: 'block'
}, 50);
var stateLabel = `${label}-tree-state`;
var widthLabel = `${label}-tree-width`;
var treeId = options.treeId || '#sidenav-left';
var toggleId = options.toggleId;
}
function closeSideNav(navId) {
if (!navId) {
navId = '#sidenav-left';
}
sessionStorage.setItem('inventree-sidenav-state', 'closed');
$(navId).animate({
// Initially hide the tree
$(treeId).animate({
width: '0px',
'min-width': '0px',
display: 'none',
}, 0, function() {
if (resize) {
$(treeId).resizable({
minWidth: '0px',
maxWidth: '500px',
handles: 'e, se',
grid: [5, 5],
stop: function(event, ui) {
var width = Math.round(ui.element.width());
if (width < 75) {
$(treeId).animate({
width: '0px'
}, 50);
//document.getElementById("sidenav").style.display = "none";
//document.getElementById("sidenav").style.width = "0";
//document.getElementById("inventree-content").style.marginLeft = "0px";
}
function toggleSideNav(nav) {
if ($(nav).width() <= 0) {
openSideNav(nav);
sessionStorage.setItem(stateLabel, 'closed');
} else {
sessionStorage.setItem(stateLabel, 'open');
sessionStorage.setItem(widthLabel, `${width}px`);
}
else {
closeSideNav(nav);
}
}
function initSideNav(navId) {
// Make it resizable
if (!navId) {
navId = '#sidenav-left';
});
}
$(navId).resizable({
minWidth: '100px',
maxWidth: '500px',
stop: function(event, ui) {
console.log(ui.element.width());
//console.log(ui.size.width);
var state = sessionStorage.getItem(stateLabel);
var width = sessionStorage.getItem(widthLabel) || '300px';
if (state && state == 'open') {
$(treeId).animate({
width: width,
}, 50);
}
});
if (sessionStorage.getItem("inventree-sidenav-state") && sessionStorage.getItem('inventree-sidenav-state') == 'open') {
openSideNav(navId);
// Register callback for 'toggle' button
if (toggleId) {
$(toggleId).click(function() {
var state = sessionStorage.getItem(stateLabel) || 'closed';
var width = sessionStorage.getItem(widthLabel) || '300px';
if (state == 'open') {
$(treeId).animate({
width: '0px'
}, 50);
sessionStorage.setItem(stateLabel, 'closed');
} else {
$(treeId).animate({
width: width,
}, 50);
sessionStorage.setItem(stateLabel, 'open');
}
else {
closeSideNav(navId);
});
}
}
/**
* Handle left-hand icon menubar display
*/

View File

@ -43,7 +43,7 @@ from .views import DynamicJsView
from common.views import SettingEdit
from .api import InfoView
from .api import InfoView, NotFoundView
from .api import ActionPluginView
from users.urls import user_urls
@ -70,6 +70,9 @@ apipatterns = [
# InvenTree information endpoint
url(r'^$', InfoView.as_view(), name='api-inventree-info'),
# Unknown endpoint
url(r'^.*$', NotFoundView.as_view(), name='api-404'),
]
settings_urls = [

View File

@ -9,6 +9,9 @@ import common.models
INVENTREE_SW_VERSION = "0.1.7 pre"
# Increment this number whenever there is a significant change to the API that any clients need to know about
INVENTREE_API_VERSION = 2
def inventreeInstanceName():
""" Returns the InstanceName settings for the current database """
@ -20,6 +23,10 @@ def inventreeVersion():
return INVENTREE_SW_VERSION
def inventreeApiVersion():
return INVENTREE_API_VERSION
def inventreeDjangoVersion():
""" Return the version of Django library """
return django.get_version()

View File

@ -22,7 +22,7 @@ InvenTree | {% trans "Supplier List" %}
</div>
{% endif %}
<table class='table table-striped' id='company-table' data-toolbar='#button-toolbar'>
<table class='table table-striped table-condensed' id='company-table' data-toolbar='#button-toolbar'>
</table>
{% endblock %}

View File

@ -397,10 +397,10 @@ class PartList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
@ -445,7 +445,9 @@ class PartList(generics.ListCreateAPIView):
a) For HTTP requests (e.g. via the browseable API) return a DRF response
b) For AJAX requests, simply return a JSON rendered response.
"""
if request.is_ajax():
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
@ -641,15 +643,18 @@ class PartList(generics.ListCreateAPIView):
queryset = queryset.filter(pk__in=parts_need_stock)
# Limit number of results
limit = params.get('limit', None)
# Optionally limit the maximum number of returned results
# e.g. for displaying "recent part" list
max_results = params.get('max_results', None)
if limit is not None:
if max_results is not None:
try:
limit = int(limit)
if limit > 0:
queryset = queryset[:limit]
except ValueError:
max_results = int(max_results)
if max_results > 0:
queryset = queryset[:max_results]
except (ValueError):
pass
return queryset
@ -674,6 +679,8 @@ class PartList(generics.ListCreateAPIView):
ordering_fields = [
'name',
'creation_date',
'IPN',
'in_stock',
]
# Default ordering
@ -685,6 +692,7 @@ class PartList(generics.ListCreateAPIView):
'IPN',
'revision',
'keywords',
'category__name',
]

View File

@ -29,8 +29,6 @@ InvenTree | {% trans "Part List" %}
{% block js_ready %}
{{ block.super }}
closeSideNav();
loadTree("{% url 'api-part-tree' %}",
"#part-tree",
{
@ -38,11 +36,9 @@ InvenTree | {% trans "Part List" %}
}
);
$("#toggle-part-tree").click(function() {
toggleSideNav("#sidenav-left");
return false;
initNavTree({
label: 'part',
treeId: '#sidenav-left',
toggleId: '#toggle-part-tree',
});
initSideNav();
{% endblock %}

View File

@ -235,6 +235,21 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_paginate(self):
"""
Test pagination of the Part list API
"""
for n in [1, 5, 10]:
response = self.get(reverse('api-part-list'), {'limit': n})
data = response.data
self.assertIn('count', data)
self.assertIn('results', data)
self.assertEqual(len(data['results']), n)
class PartAPIAggregationTest(InvenTreeAPITestCase):
"""

View File

@ -381,6 +381,11 @@ class StockList(generics.ListCreateAPIView):
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
else:
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
@ -465,7 +470,9 @@ class StockList(generics.ListCreateAPIView):
Note: b) is about 100x quicker than a), because the DRF framework adds a lot of cruft
"""
if request.is_ajax():
if page is not None:
return self.get_paginated_response(data)
elif request.is_ajax():
return JsonResponse(data, safe=False)
else:
return Response(data)
@ -806,16 +813,15 @@ class StockList(generics.ListCreateAPIView):
print("After error:", str(updated_after))
pass
# Limit number of results
limit = params.get('limit', None)
# Optionally, limit the maximum number of returned results
max_results = params.get('max_results', None)
if limit is not None:
if max_results is not None:
try:
limit = int(limit)
if limit > 0:
queryset = queryset[:limit]
max_results = int(max_results)
if max_results > 0:
queryset = queryset[:max_results]
except (ValueError):
pass
@ -839,9 +845,12 @@ class StockList(generics.ListCreateAPIView):
ordering_fields = [
'part__name',
'part__IPN',
'updated',
'stocktake_date',
'expiry_date',
'quantity',
'status',
]
ordering = ['part__name']
@ -851,7 +860,8 @@ class StockList(generics.ListCreateAPIView):
'batch',
'part__name',
'part__IPN',
'part__description'
'part__description',
'location__name',
]

View File

@ -52,12 +52,10 @@
loadStockTrackingTable($("#track-table"), {
params: function(p) {
return {
params: {
ordering: '-date',
item: {{ item.pk }},
user_detail: true,
};
},
url: "{% url 'api-stock-track' %}",
});

View File

@ -11,7 +11,9 @@ InvenTree | Stock
{% endblock %}
{% block sidenav %}
<div id='stock-tree'></div>
<div id='stock-tree'>
{% trans "Loading..." %}
</div>
{% endblock %}
{% block pre_content %}
@ -24,6 +26,7 @@ InvenTree | Stock
{% block js_ready %}
{{ block.super }}
loadTree("{% url 'api-stock-tree' %}",
"#stock-tree",
{
@ -31,10 +34,10 @@ InvenTree | Stock
}
);
$("#toggle-stock-tree").click(function() {
toggleSideNav("#sidenav-left");
return false;
})
initNavTree({
label: 'stock',
treeId: '#sidenav-left',
toggleId: '#toggle-stock-tree',
});
initSideNav();
{% endblock %}

View File

@ -244,6 +244,19 @@ class StockItemListTest(StockAPITestCase):
response = self.get_stock(expired=0)
self.assertEqual(len(response), 16)
def test_paginate(self):
"""
Test that we can paginate results correctly
"""
for n in [1, 5, 10]:
response = self.get_stock(limit=n)
self.assertIn('count', response)
self.assertIn('results', response)
self.assertEqual(len(response['results']), n)
class StockItemTest(StockAPITestCase):
"""

View File

@ -102,7 +102,7 @@ addHeaderAction('bom-validation', '{% trans "BOM Waiting Validation" %}', 'fa-ti
loadSimplePartTable("#table-latest-parts", "{% url 'api-part-list' %}", {
params: {
ordering: "-creation_date",
limit: {% settings_value "PART_RECENT_COUNT" %},
max_results: {% settings_value "PART_RECENT_COUNT" %},
},
name: 'latest_parts',
});
@ -132,7 +132,7 @@ addHeaderAction('stock-to-build', '{% trans "Required for Build Orders" %}', 'fa
loadStockTable($('#table-recently-updated-stock'), {
params: {
ordering: "-updated",
limit: {% settings_value "STOCK_RECENT_COUNT" %},
max_results: {% settings_value "STOCK_RECENT_COUNT" %},
},
name: 'recently-updated-stock',
grouping: false,

View File

@ -630,6 +630,7 @@ function loadBuildTable(table, options) {
url: options.url,
queryParams: filters,
groupBy: false,
sidePagination: 'server',
name: 'builds',
original: params,
columns: [

View File

@ -93,6 +93,7 @@ function loadCompanyTable(table, url, options={}) {
method: 'get',
queryParams: filters,
groupBy: false,
sidePagination: 'server',
formatNoMatches: function() { return "{% trans "No company information found" %}"; },
showColumns: true,
name: options.pagetype || 'company',

View File

@ -131,6 +131,7 @@ function loadPurchaseOrderTable(table, options) {
queryParams: filters,
name: 'purchaseorder',
groupBy: false,
sidePagination: 'server',
original: options.params,
formatNoMatches: function() { return '{% trans "No purchase orders found" %}'; },
columns: [
@ -225,6 +226,7 @@ function loadSalesOrderTable(table, options) {
queryParams: filters,
name: 'salesorder',
groupBy: false,
sidePagination: 'server',
original: options.params,
formatNoMatches: function() { return '{% trans "No sales orders found" %}'; },
columns: [

View File

@ -366,7 +366,6 @@ function loadPartTable(table, url, options={}) {
});
columns.push({
sortable: true,
field: 'description',
title: '{% trans "Description" %}',
formatter: function(value, row, index, field) {
@ -442,12 +441,13 @@ function loadPartTable(table, url, options={}) {
$(table).inventreeTable({
url: url,
sortName: 'name',
method: 'get',
queryParams: filters,
groupBy: false,
name: options.name || 'part',
original: params,
sidePagination: 'server',
pagination: 'true',
formatNoMatches: function() { return '{% trans "No parts found" %}'; },
columns: columns,
showColumns: true,

View File

@ -325,6 +325,12 @@ function loadStockTable(table, options) {
grouping = options.grouping;
}
// Explicitly disable part grouping functionality
// Might be able to add this in later on,
// but there is a bug which makes this crash if paginating on the server side.
// Ref: https://github.com/wenzhixin/bootstrap-table/issues/3250
grouping = false;
table.inventreeTable({
method: 'get',
formatNoMatches: function() {
@ -332,7 +338,7 @@ function loadStockTable(table, options) {
},
url: options.url || "{% url 'api-stock-list' %}",
queryParams: filters,
customSort: customGroupSorter,
sidePagination: 'server',
name: 'stock',
original: original,
showColumns: true,
@ -516,6 +522,7 @@ function loadStockTable(table, options) {
{
field: 'part_detail.full_name',
title: '{% trans "Part" %}',
sortName: 'part__name',
sortable: true,
switchable: false,
formatter: function(value, row, index, field) {
@ -534,6 +541,7 @@ function loadStockTable(table, options) {
{
field: 'part_detail.IPN',
title: 'IPN',
sortName: 'part__IPN',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.IPN;
@ -542,7 +550,6 @@ function loadStockTable(table, options) {
{
field: 'part_detail.description',
title: '{% trans "Description" %}',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.description;
}
@ -654,8 +661,6 @@ function loadStockTable(table, options) {
{
field: 'packaging',
title: '{% trans "Packaging" %}',
sortable: true,
searchable: true,
},
{
field: 'notes',
@ -927,7 +932,6 @@ function loadStockTrackingTable(table, options) {
cols.push({
field: 'title',
title: '{% trans "Description" %}',
sortable: true,
formatter: function(value, row, index, field) {
var html = "<b>" + value + "</b>";
@ -952,7 +956,6 @@ function loadStockTrackingTable(table, options) {
});
cols.push({
sortable: true,
field: 'user',
title: '{% trans "User" %}',
formatter: function(value, row, index, field) {

View File

@ -1,5 +1,6 @@
{% load i18n %}
{% load status_codes %}
{% load inventree_extras %}
{% include "status_codes.html" with label='stock' options=StockStatus.list %}
{% include "status_codes.html" with label='build' options=BuildStatus.list %}
@ -110,6 +111,8 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Depleted" %}',
description: '{% trans "Show stock items which are depleted" %}',
},
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %}
expired: {
type: 'bool',
title: '{% trans "Expired" %}',
@ -120,6 +123,7 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Stale" %}',
description: '{% trans "Show stock which is close to expiring" %}',
},
{% endif %}
in_stock: {
type: 'bool',
title: '{% trans "In Stock" %}',

View File

@ -93,7 +93,14 @@ function reloadTable(table, filters) {
}
}
options.queryParams = params;
options.queryParams = function(tableParams) {
for (key in params) {
tableParams[key] = params[key];
}
return tableParams;
}
table.bootstrapTable('refreshOptions', options);
table.bootstrapTable('refresh');
@ -126,9 +133,45 @@ $.fn.inventreeTable = function(options) {
var varName = tableName + '-pagesize';
// Pagingation options (can be server-side or client-side as specified by the caller)
options.pagination = true;
options.paginationVAlign = 'both';
options.pageSize = inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all'];
options.totalField = 'count';
options.dataField = 'results';
// Extract query params
var filters = options.queryParams || options.filters || {};
options.queryParams = function(params) {
for (var key in filters) {
params[key] = filters[key];
}
// Override the way that we ask the server to sort results
// It seems bootstrap-table does not offer a "native" way to do this...
if ('sort' in params) {
var order = params['order'];
var ordering = params['sort'] || null;
if (ordering) {
if (order == 'desc') {
ordering = `-${ordering}`;
}
params['ordering'] = ordering;
}
delete params['sort'];
delete params['order'];
}
return params;
}
options.rememberOrder = true;

View File

@ -5,8 +5,8 @@ from . import api
user_urls = [
url(r'^(?P<pk>[0-9]+)/?$', api.UserDetail.as_view(), name='user-detail'),
url(r'roles', api.RoleDetails.as_view(), name='api-user-roles'),
url(r'token', api.GetAuthToken.as_view(), name='api-token'),
url(r'roles/?$', api.RoleDetails.as_view(), name='api-user-roles'),
url(r'token/?$', api.GetAuthToken.as_view(), name='api-token'),
url(r'^$', api.UserList.as_view()),
]

View File

@ -9,6 +9,14 @@ InvenTree is designed to be lightweight and easy to use for SME or hobbyist appl
However, powerful business logic works in the background to ensure that stock tracking history is maintained, and users have ready access to stock level information.
# Companion App
InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality.
[**Download InvenTree from the Android Play Store**](https://play.google.com/store/apps/details?id=inventree.inventree_app)
*Currently the mobile app is only availble for Android*
# Documentation
For InvenTree documentation, refer to the [InvenTree documentation website](https://inventree.readthedocs.io/en/latest/).