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 %} - -{% 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 %} +