diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index ab946b7dcb..a2d7609bed 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -60,28 +60,43 @@ class CategoryList(generics.ListCreateAPIView):
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer
- def get_queryset(self):
+ def filter_queryset(self, queryset):
"""
Custom filtering:
- Allow filtering by "null" parent to retrieve top-level part categories
"""
- cat_id = self.request.query_params.get('parent', None)
+ queryset = super().filter_queryset(queryset)
- queryset = super().get_queryset()
+ params = self.request.query_params
- if cat_id is not None:
+ cat_id = params.get('parent', None)
+
+ cascade = str2bool(params.get('cascade', False))
+
+ # Do not filter by category
+ if cat_id is None:
+ pass
+ # Look for top-level categories
+ elif isNull(cat_id):
- # Look for top-level categories
- if isNull(cat_id):
+ if not cascade:
queryset = queryset.filter(parent=None)
-
- else:
- try:
- cat_id = int(cat_id)
- queryset = queryset.filter(parent=cat_id)
- except ValueError:
- pass
+
+ else:
+ try:
+ category = PartCategory.objects.get(pk=cat_id)
+
+ if cascade:
+ parents = category.get_descendants(include_self=True)
+ parent_ids = [p.id for p in parents]
+
+ queryset = queryset.filter(parent__in=parent_ids)
+ else:
+ queryset = queryset.filter(parent=category)
+
+ except (ValueError, PartCategory.DoesNotExist):
+ pass
return queryset
diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 0d1d11e7fd..04594afb3b 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -2,6 +2,10 @@
{% load static %}
{% load i18n %}
+{% block menubar %}
+{% include 'part/category_navbar.html' with tab='parts' %}
+{% endblock %}
+
{% block content %}
@@ -100,14 +104,10 @@
- {% if category and category.children.all|length > 0 %}
- {% include "part/subcategories.html" with children=category.children.all collapse_id="categories" %}
- {% elif children|length > 0 %}
- {% include "part/subcategories.html" with children=children collapse_id="categories" %}
- {% endif %}
-
+{% block category_content %}
+
+{% endblock %}
+
{% block category_tables %}
{% endblock category_tables %}
@@ -162,24 +164,10 @@
{% block js_ready %}
{{ block.super }}
- {% if category %}
enableNavbar({
label: 'category',
toggleId: '#category-menu-toggle',
});
- {% endif %}
-
- if (inventreeLoadInt("show-part-cats") == 1) {
- $("#collapse-item-categories").collapse('show');
- }
-
- $("#collapse-item-categories").on('shown.bs.collapse', function() {
- inventreeSave('show-part-cats', 1);
- });
-
- $("#collapse-item-categories").on('hidden.bs.collapse', function() {
- inventreeDel('show-part-cats');
- });
$("#cat-create").click(function() {
launchModalForm(
diff --git a/InvenTree/part/templates/part/category_navbar.html b/InvenTree/part/templates/part/category_navbar.html
index 9374ecaaf1..e723db358d 100644
--- a/InvenTree/part/templates/part/category_navbar.html
+++ b/InvenTree/part/templates/part/category_navbar.html
@@ -8,17 +8,34 @@
+
+ {% if category %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% trans "Subcategories" %}
+
+
+
+ {% if category %}
+ {% else %}
+
+ {% endif %}
{% trans "Parts" %}
+ {% if category %}
{% trans "Parameters" %}
+ {% endif %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/subcategories.html b/InvenTree/part/templates/part/subcategories.html
deleted file mode 100644
index 5e6b570217..0000000000
--- a/InvenTree/part/templates/part/subcategories.html
+++ /dev/null
@@ -1,22 +0,0 @@
-{% extends "collapse.html" %}
-{% load i18n %}
-
-{% block collapse_title %}
-{{ children | length }} {% trans 'Child Categories' %}
-{% endblock %}
-
-{% block collapse_content %}
-
-{% for child in children %}
--
- {{ child.name }}
- {% if child.description %}
- - {{ child.description }}
- {% endif %}
- {% if child.partcount > 0 %}
- {{ child.partcount }} {% trans 'Part' %}{% if child.partcount > 1 %}s{% endif %}
- {% endif %}
-
-{% endfor %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/subcategory.html b/InvenTree/part/templates/part/subcategory.html
new file mode 100644
index 0000000000..9bb8fe98b7
--- /dev/null
+++ b/InvenTree/part/templates/part/subcategory.html
@@ -0,0 +1,51 @@
+{% extends "part/category.html" %}
+
+{% load i18n %}
+{% load inventree_extras %}
+{% load static %}
+
+{% block menubar %}
+{% include 'part/category_navbar.html' with tab='subcategories' %}
+{% endblock %}
+
+{% block category_content %}
+
+
+
+
+
{% trans "Subcategories" %}
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+ enableNavbar({
+ label: 'category',
+ toggleId: '#category-menu-toggle',
+ });
+
+ loadPartCategoryTable($('#subcategory-table'), {
+ params: {
+ {% if category %}
+ parent: {{ category.pk }}
+ {% else %}
+ parent: 'null'
+ {% endif %}
+ }
+ });
+
+{% endblock %}
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index ed88e1dd55..4389003544 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -37,12 +37,54 @@ class PartAPITest(InvenTreeAPITestCase):
super().setUp()
def test_get_categories(self):
- """ Test that we can retrieve list of part categories """
+ """
+ Test that we can retrieve list of part categories,
+ with various filtering options.
+ """
+
url = reverse('api-part-category-list')
+
+ # Request *all* part categories
response = self.client.get(url, format='json')
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 8)
+ # Request top-level part categories only
+ response = self.client.get(
+ url,
+ {
+ 'parent': 'null',
+ },
+ format='json'
+ )
+
+ self.assertEqual(len(response.data), 2)
+
+ # Children of PartCategory<1>, cascade
+ response = self.client.get(
+ url,
+ {
+ 'parent': 1,
+ 'cascade': 'true',
+ },
+ format='json',
+ )
+
+ self.assertEqual(len(response.data), 5)
+
+ # Children of PartCategory<1>, do not cascade
+ response = self.client.get(
+ url,
+ {
+ 'parent': 1,
+ 'cascade': 'false',
+ },
+ format='json',
+ )
+
+ self.assertEqual(len(response.data), 3)
+
def test_add_categories(self):
""" Check that we can add categories """
data = {
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index f1185fbe8c..b90b11b568 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -88,14 +88,26 @@ category_parameter_urls = [
url(r'^(?P\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'),
]
-part_category_urls = [
- url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
- url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
+category_urls = [
- url(r'^parameters/', include(category_parameter_urls)),
+ # Create a new category
+ url(r'^new/', views.CategoryCreate.as_view(), name='category-create'),
- url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'),
- url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
+ # Top level subcategory display
+ url(r'^subcategory/', views.PartIndex.as_view(template_name='part/subcategory.html'), name='category-index-subcategory'),
+
+ # Category detail views
+ url(r'(?P\d+)/', include([
+ url(r'^edit/', views.CategoryEdit.as_view(), name='category-edit'),
+ url(r'^delete/', views.CategoryDelete.as_view(), name='category-delete'),
+ url(r'^parameters/', include(category_parameter_urls)),
+
+ url(r'^subcategory/', views.CategoryDetail.as_view(template_name='part/subcategory.html'), name='category-subcategory'),
+ url(r'^parametric/', views.CategoryParametric.as_view(), name='category-parametric'),
+
+ # Anything else
+ url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
+ ]))
]
part_bom_urls = [
@@ -106,9 +118,6 @@ part_bom_urls = [
# URL list for part web interface
part_urls = [
- # Create a new category
- url(r'^category/new/?', views.CategoryCreate.as_view(), name='category-create'),
-
# Create a new part
url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
@@ -125,7 +134,7 @@ part_urls = [
url(r'^(?P\d+)/', include(part_detail_urls)),
# Part category
- url(r'^category/(?P\d+)/', include(part_category_urls)),
+ url(r'^category/', include(category_urls)),
# Part related
url(r'^related-parts/', include(part_related_urls)),
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 6da3962224..0e64cecdbd 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -281,28 +281,46 @@ class StockLocationList(generics.ListCreateAPIView):
queryset = StockLocation.objects.all()
serializer_class = LocationSerializer
- def get_queryset(self):
+ def filter_queryset(self, queryset):
"""
Custom filtering:
- Allow filtering by "null" parent to retrieve top-level stock locations
"""
- queryset = super().get_queryset()
+ queryset = super().filter_queryset(queryset)
- loc_id = self.request.query_params.get('parent', None)
+ params = self.request.query_params
- if loc_id is not None:
+ loc_id = params.get('parent', None)
+
+ cascade = str2bool(params.get('cascade', False))
- # Look for top-level locations
- if isNull(loc_id):
+ # Do not filter by location
+ if loc_id is None:
+ pass
+ # Look for top-level locations
+ elif isNull(loc_id):
+
+ # If we allow "cascade" at the top-level, this essentially means *all* locations
+ if not cascade:
queryset = queryset.filter(parent=None)
-
- else:
- try:
- loc_id = int(loc_id)
- queryset = queryset.filter(parent=loc_id)
- except ValueError:
- pass
+
+ else:
+
+ try:
+ location = StockLocation.objects.get(pk=loc_id)
+
+ # All sub-locations to be returned too?
+ if cascade:
+ parents = location.get_descendants(include_self=True)
+ parent_ids = [p.id for p in parents]
+ queryset = queryset.filter(parent__in=parent_ids)
+
+ else:
+ queryset = queryset.filter(parent=location)
+
+ except (ValueError, StockLocation.DoesNotExist):
+ pass
return queryset
@@ -320,6 +338,11 @@ class StockLocationList(generics.ListCreateAPIView):
'description',
]
+ ordering_fields = [
+ 'name',
+ 'items',
+ ]
+
class StockList(generics.ListCreateAPIView):
""" API endpoint for list view of Stock objects
diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html
index 74e43f88bb..396a433566 100644
--- a/InvenTree/stock/templates/stock/location.html
+++ b/InvenTree/stock/templates/stock/location.html
@@ -2,8 +2,15 @@
{% load static %}
{% load inventree_extras %}
{% load i18n %}
+
+{% block menubar %}
+{% include "stock/location_navbar.html" with tab="stock" %}
+{% endblock %}
+
{% block content %}
+
+
{% setting_object 'STOCK_OWNERSHIP_CONTROL' as owner_control %}
{% if owner_control.value == "True" %}
{% authorized_owners location.owner as owners %}
@@ -120,36 +127,29 @@
+
-{% if location and location.children.all|length > 0 %}
-{% include 'stock/location_list.html' with children=location.children.all collapse_id="locations" %}
-{% elif locations|length > 0 %}
-{% include 'stock/location_list.html' with children=locations collapse_id="locations" %}
-{% endif %}
+{% block location_content %}
-
-
-{% include "stock_table.html" %}
+
+
+
{% trans "Stock Items" %}
+
+ {% include "stock_table.html" %}
{% endblock %}
-{% block js_load %}
-{{ block.super }}
+
+
{% endblock %}
+
{% block js_ready %}
{{ block.super }}
- if (inventreeLoadInt("show-part-locs") == 1) {
- $("#collapse-item-locations").collapse('show');
- }
-
- $("#collapse-item-locations").on('shown.bs.collapse', function() {
- inventreeSave('show-part-locs', 1);
- });
-
- $("#collapse-item-locations").on('hidden.bs.collapse', function() {
- inventreeDel('show-part-locs');
+ enableNavbar({
+ label: 'location',
+ toggleId: '#location-menu-toggle'
});
{% if location %}
@@ -261,7 +261,7 @@
],
params: {
{% if location %}
- location: {{ location.id }},
+ location: {{ location.pk }},
{% endif %}
part_detail: true,
location_detail: true,
diff --git a/InvenTree/stock/templates/stock/location_list.html b/InvenTree/stock/templates/stock/location_list.html
deleted file mode 100644
index f9464c5fa3..0000000000
--- a/InvenTree/stock/templates/stock/location_list.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends "collapse.html" %}
-{% load i18n %}
-
-{% if roles.stock_location.view or roles.stock.view %}
-{% block collapse_title %}
-{% trans 'Sub-Locations' %}{{ children|length }}
-{% endblock %}
-
-{% block collapse_content %}
-
-{% for child in children %}
-- {{ child.name }} - {{ child.description }}
- {% if child.item_count > 0 %}
-
-
- {% comment %}Translators: pluralize with counter{% endcomment %}
- {% blocktrans count counter=child.item_count %}{{ counter }} Item{% plural %}{{ counter }} Items{% endblocktrans %}
-
- {% endif %}
-
-{% endfor %}
-
-{% endblock %}
-{% endif %}
\ No newline at end of file
diff --git a/InvenTree/stock/templates/stock/location_navbar.html b/InvenTree/stock/templates/stock/location_navbar.html
new file mode 100644
index 0000000000..0cb0c9d1eb
--- /dev/null
+++ b/InvenTree/stock/templates/stock/location_navbar.html
@@ -0,0 +1,33 @@
+{% load i18n %}
+
+
\ No newline at end of file
diff --git a/InvenTree/stock/templates/stock/sublocation.html b/InvenTree/stock/templates/stock/sublocation.html
new file mode 100644
index 0000000000..24b034449e
--- /dev/null
+++ b/InvenTree/stock/templates/stock/sublocation.html
@@ -0,0 +1,74 @@
+{% extends "stock/location.html" %}
+
+{% load static %}
+{% load i18n %}
+{% load inventree_extras %}
+
+{% block menubar %}
+{% include "stock/location_navbar.html" with tab="sublocations" %}
+{% endblock %}
+
+
+{% block location_content %}
+
+
+
+
{% trans "Sublocations" %}
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+loadStockLocationTable($('#sublocation-table'), {
+ params: {
+ {% if location %}
+ parent: {{ location.pk }},
+ {% else %}
+ parent: 'null',
+ {% endif %}
+ }
+});
+
+linkButtonsToSelection(
+ $('#sublocation-table'),
+ [
+ '#location-print-options',
+ ]
+);
+
+$('#multi-location-print-label').click(function() {
+
+ var selections = $('#sublocation-table').bootstrapTable('getSelections');
+
+ var locations = [];
+
+ selections.forEach(function(loc) {
+ locations.push(loc.pk);
+ });
+
+ printStockLocationLabels(locations);
+})
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py
index 5c6c678978..24e609fa4f 100644
--- a/InvenTree/stock/urls.py
+++ b/InvenTree/stock/urls.py
@@ -6,14 +6,21 @@ from django.conf.urls import url, include
from . import views
-# URL list for web interface
-stock_location_detail_urls = [
- url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'),
- url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
- url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
+location_urls = [
+
+ url(r'^new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
+
+ url(r'^(?P\d+)/', include([
+ url(r'^edit/?', views.StockLocationEdit.as_view(), name='stock-location-edit'),
+ url(r'^delete/?', views.StockLocationDelete.as_view(), name='stock-location-delete'),
+ url(r'^qr_code/?', views.StockLocationQRCode.as_view(), name='stock-location-qr'),
+
+ url(r'sublocation/', views.StockLocationDetail.as_view(template_name='stock/sublocation.html'), name='stock-location-sublocation'),
+
+ # Anything else
+ url('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
+ ])),
- # Anything else
- url('^.*$', views.StockLocationDetail.as_view(), name='stock-location-detail'),
]
stock_item_detail_urls = [
@@ -49,9 +56,7 @@ stock_tracking_urls = [
stock_urls = [
# Stock location
- url(r'^location/(?P\d+)/', include(stock_location_detail_urls)),
-
- url(r'^location/new/', views.StockLocationCreate.as_view(), name='stock-location-create'),
+ url(r'^location/', include(location_urls)),
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
@@ -81,5 +86,7 @@ stock_urls = [
# Individual stock items
url(r'^item/(?P\d+)/', include(stock_item_detail_urls)),
+ url(r'^sublocations/', views.StockIndex.as_view(template_name='stock/sublocation.html'), name='stock-sublocations'),
+
url(r'^.*$', views.StockIndex.as_view(), name='stock-index'),
]
diff --git a/InvenTree/templates/js/part.js b/InvenTree/templates/js/part.js
index cefc2af8a7..e3e7190952 100644
--- a/InvenTree/templates/js/part.js
+++ b/InvenTree/templates/js/part.js
@@ -1,4 +1,5 @@
{% load i18n %}
+{% load inventree_extras %}
/* Part API functions
* Requires api.js to be loaded first
@@ -506,6 +507,82 @@ function loadPartTable(table, url, options={}) {
});
}
+
+function loadPartCategoryTable(table, options) {
+ /* Display a table of part categories */
+
+ var params = options.params || {};
+
+ var filterListElement = options.filterList || '#filter-list-category';
+
+ var filters = {};
+
+ var filterKey = options.filterKey || options.name || 'category';
+
+ if (!options.disableFilters) {
+ filters = loadTableFilters(filterKey);
+ }
+
+ var original = {};
+
+ for (var key in params) {
+ original[key] = params[key];
+ filters[key] = params[key];
+ }
+
+ setupFilterList(filterKey, table, filterListElement);
+
+ table.inventreeTable({
+ method: 'get',
+ url: options.url || '{% url "api-part-category-list" %}',
+ queryParams: filters,
+ sidePagination: 'server',
+ name: 'category',
+ original: original,
+ showColumns: true,
+ columns: [
+ {
+ checkbox: true,
+ title: '{% trans "Select" %}',
+ searchable: false,
+ switchable: false,
+ visible: false,
+ },
+ {
+ field: 'name',
+ title: '{% trans "Name" %}',
+ switchable: true,
+ sortable: true,
+ formatter: function(value, row) {
+ return renderLink(
+ value,
+ `/part/category/${row.pk}/`
+ );
+ }
+ },
+ {
+ field: 'description',
+ title: '{% trans "Description" %}',
+ switchable: true,
+ sortable: false,
+ },
+ {
+ field: 'pathstring',
+ title: '{% trans "Path" %}',
+ switchable: true,
+ sortable: false,
+ },
+ {
+ field: 'parts',
+ title: '{% trans "Parts" %}',
+ switchable: true,
+ sortable: false,
+ }
+ ]
+ });
+}
+
+
function yesNoLabel(value) {
if (value) {
return `{% trans "YES" %}`;
diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js
index 33f2dae8d6..c10e432f5e 100644
--- a/InvenTree/templates/js/stock.js
+++ b/InvenTree/templates/js/stock.js
@@ -897,6 +897,83 @@ function loadStockTable(table, options) {
});
}
+function loadStockLocationTable(table, options) {
+ /* Display a table of stock locations */
+
+ var params = options.params || {};
+
+ var filterListElement = options.filterList || '#filter-list-location';
+
+ var filters = {};
+
+ var filterKey = options.filterKey || options.name || 'location';
+
+ if (!options.disableFilters) {
+ filters = loadTableFilters(filterKey);
+ }
+
+ var original = {};
+
+ for (var key in params) {
+ original[key] = params[key];
+ }
+
+ setupFilterList(filterKey, table, filterListElement);
+
+ for (var key in params) {
+ filters[key] = params[key];
+ }
+
+ table.inventreeTable({
+ method: 'get',
+ url: options.url || '{% url "api-location-list" %}',
+ queryParams: filters,
+ sidePagination: 'server',
+ name: 'location',
+ original: original,
+ showColumns: true,
+ columns: [
+ {
+ checkbox: true,
+ title: '{% trans "Select" %}',
+ searchable: false,
+ switchable: false,
+ },
+ {
+ field: 'name',
+ title: '{% trans "Name" %}',
+ switchable: true,
+ sortable: true,
+ formatter: function(value, row) {
+ return renderLink(
+ value,
+ `/stock/location/${row.pk}/`
+ );
+ },
+ },
+ {
+ field: 'description',
+ title: '{% trans "Description" %}',
+ switchable: true,
+ sortable: false,
+ },
+ {
+ field: 'pathstring',
+ title: '{% trans "Path" %}',
+ switchable: true,
+ sortable: false,
+ },
+ {
+ field: 'items',
+ title: '{% trans "Stock Items" %}',
+ switchable: true,
+ sortable: false,
+ sortName: 'item_count',
+ }
+ ]
+ });
+}
+
function loadStockTrackingTable(table, options) {
var cols = [
diff --git a/InvenTree/templates/js/table_filters.js b/InvenTree/templates/js/table_filters.js
index ba73244c74..775f0d9803 100644
--- a/InvenTree/templates/js/table_filters.js
+++ b/InvenTree/templates/js/table_filters.js
@@ -62,6 +62,28 @@ function getAvailableTableFilters(tableKey) {
};
}
+ // Filters for "stock location" table
+ if (tableKey == "location") {
+ return {
+ cascade: {
+ type: 'bool',
+ title: '{% trans "Include sublocations" %}',
+ description: '{% trans "Include locations" %}',
+ }
+ };
+ }
+
+ // Filters for "part category" table
+ if (tableKey == "category") {
+ return {
+ cascade: {
+ type: 'bool',
+ title: '{% trans "Include subcategories" %}',
+ description: '{% trans "Include subcategories" %}',
+ }
+ };
+ }
+
// Filters for the "customer stock" table (really a subset of "stock")
if (tableKey == "customerstock") {
return {
diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html
index 8eff0f4aed..1f54767f02 100644
--- a/InvenTree/templates/stock_table.html
+++ b/InvenTree/templates/stock_table.html
@@ -32,6 +32,7 @@
{% endif %}
+