From 6b48977e7b5d183ebeb852a06918cf7a683b88a4 Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Tue, 29 Sep 2020 15:16:12 -0500
Subject: [PATCH 01/77] Added 'Parametric Table' tab to category detail view,
 added part_count to 'Parts' tab

---
 InvenTree/part/templates/part/category.html   | 15 +++++++
 .../templates/part/category_parametric.html   | 12 +++++
 .../templates/part/category_partlist.html     | 12 +++++
 .../part/templates/part/category_tabs.html    | 11 +++++
 InvenTree/part/urls.py                        |  3 +-
 InvenTree/part/views.py                       | 13 +++++-
 InvenTree/templates/js/part.html              | 45 +++++++++++++++++++
 7 files changed, 109 insertions(+), 2 deletions(-)
 create mode 100644 InvenTree/part/templates/part/category_parametric.html
 create mode 100644 InvenTree/part/templates/part/category_partlist.html
 create mode 100644 InvenTree/part/templates/part/category_tabs.html

diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 1c7980ba9b..1f5ee7c48b 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -115,8 +115,11 @@
     </div>
 </div>
 
+
+{% block part_list %}
 <table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
 </table>
+{% endblock part_list %}
 
 {% endblock %}
 {% block js_load %}
@@ -242,4 +245,16 @@
         },
     );
 
+    loadParametricPartTable(
+        "#parametric-part-table",
+        "{% url 'api-part-list' %}",
+        {
+            params: {
+                {% if category %}category: {{ category.id }},
+                {% else %}category: "null",
+                {% endif %}
+            },
+        },
+    );
+
 {% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/category_parametric.html b/InvenTree/part/templates/part/category_parametric.html
new file mode 100644
index 0000000000..7139a539b0
--- /dev/null
+++ b/InvenTree/part/templates/part/category_parametric.html
@@ -0,0 +1,12 @@
+{% extends "part/category.html" %}
+{% load static %}
+{% load i18n %}
+
+{% block part_list %}
+
+{% include 'part/category_tabs.html' with tab='parametric-table' %}
+
+<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='parametric-part-table'>
+</table>
+
+{% endblock %}
diff --git a/InvenTree/part/templates/part/category_partlist.html b/InvenTree/part/templates/part/category_partlist.html
new file mode 100644
index 0000000000..bf4aef52a8
--- /dev/null
+++ b/InvenTree/part/templates/part/category_partlist.html
@@ -0,0 +1,12 @@
+{% extends "part/category.html" %}
+{% load static %}
+{% load i18n %}
+
+{% block part_list %}
+
+{% include 'part/category_tabs.html' with tab='part-list' %}
+
+<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
+</table>
+
+{% endblock %}
diff --git a/InvenTree/part/templates/part/category_tabs.html b/InvenTree/part/templates/part/category_tabs.html
new file mode 100644
index 0000000000..b5d8d3c214
--- /dev/null
+++ b/InvenTree/part/templates/part/category_tabs.html
@@ -0,0 +1,11 @@
+{% load i18n %}
+{% load inventree_extras %}
+
+<ul class="nav nav-tabs">
+    <li{% ifequal tab 'part-list' %} class="active"{% endifequal %}>
+        <a href="{% url 'category-detail' category.id %}">{% trans "Parts" %} <span class="badge">{% decimal part_count %}</span></a>
+    </li>
+    <li{% ifequal tab 'parametric-table' %} class='active'{% endifequal %}>
+        <a href="{% url 'category-parametric' category.id %}">{% trans "Parametric Table" %}</a>
+    </li>
+</ul>
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index e61947e243..88d15c1b5b 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -77,7 +77,8 @@ part_category_urls = [
     url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
     url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
 
-    url('^.*$', views.CategoryDetail.as_view(), name='category-detail'),
+    url(r'^parametric/?', views.CategoryDetail.as_view(template_name='part/category_parametric.html'), name='category-parametric'),
+    url(r'^.*$', views.CategoryDetail.as_view(template_name='part/category_partlist.html'), name='category-detail'),
 ]
 
 part_bom_urls = [
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index ccf607afc0..3c94afc5a5 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -1875,7 +1875,18 @@ class CategoryDetail(DetailView):
     model = PartCategory
     context_object_name = 'category'
     queryset = PartCategory.objects.all().prefetch_related('children')
-    template_name = 'part/category.html'
+    template_name = 'part/category_partlist.html'
+
+    def get_context_data(self, **kwargs):
+
+        context = super(CategoryDetail, self).get_context_data(**kwargs).copy()
+
+        try:
+            context['part_count'] = kwargs['object'].partcount()
+        except KeyError:
+            context['part_count'] = 0
+
+        return context
 
 
 class CategoryEdit(AjaxUpdateView):
diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index 5576d91367..0b32ef61b9 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -163,6 +163,51 @@ function loadSimplePartTable(table, url, options={}) {
 }
 
 
+function loadParametricPartTable(table, url, options={}) {
+    /* Load parametric part data into specified table.
+     * 
+     * Args:
+     *  - table: HTML reference to the table
+     *  - url: Base URL for API query
+     */
+
+    // Ensure category detail is included
+    options.params['category_detail'] = true;
+
+    var params = options.params || {};
+    console.log(params)
+
+    var filters = {};
+    for (var key in params) {
+        filters[key] = params[key];
+    }
+    console.log(filters)
+
+    var columns = [
+        {
+            field: 'pk',
+            title: 'ID',
+            visible: true,
+            switchable: true,
+            searchable: false,
+        }
+    ];
+
+    $(table).inventreeTable({
+        url: url,
+        sortName: 'pk',
+        method: 'get',
+        queryParams: filters,
+        groupBy: false,
+        name: options.name || 'part',
+        original: params,
+        formatNoMatches: function() { return "{% trans "No parts found" %}"; },
+        columns: columns,
+        showColumns: true,
+    });
+}
+
+
 function loadPartTable(table, url, options={}) {
     /* Load part listing data into specified table.
      * 

From d05a5978a044662af8df7ec3a3ea6f0c0163387b Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Tue, 29 Sep 2020 16:13:08 -0500
Subject: [PATCH 02/77] Unique parameters names from category makes it to
 bootstrap table

---
 InvenTree/part/models.py                      | 14 ++++
 InvenTree/part/templates/part/category.html   | 16 +---
 .../templates/part/category_parametric.html   | 21 ++++-
 .../templates/part/category_partlist.html     |  2 +-
 InvenTree/part/urls.py                        |  4 +-
 InvenTree/part/views.py                       | 15 ++++
 InvenTree/templates/js/part.html              | 80 +++++++++++++++++--
 7 files changed, 127 insertions(+), 25 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index afb2dfa64e..707b0b948c 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -111,6 +111,20 @@ class PartCategory(InvenTreeTree):
         """ True if there are any parts in this category """
         return self.partcount() > 0
 
+    def get_unique_parameters(self, cascade=True):
+        """ Get all parameters for all parts from this category """
+        parameters = []
+
+        parts = self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template')
+
+        for part in parts:
+            for parameter in part.parameters.all():
+                template_name = parameter.template.name
+                if template_name not in parameters:
+                    parameters.append(template_name)
+
+        return parameters
+
 
 @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
 def before_delete_part_category(sender, instance, using, **kwargs):
diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 1f5ee7c48b..0f5ba6de43 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -116,10 +116,10 @@
 </div>
 
 
-{% block part_list %}
+{% block category_tables %}
 <table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
 </table>
-{% endblock part_list %}
+{% endblock category_tables %}
 
 {% endblock %}
 {% block js_load %}
@@ -245,16 +245,4 @@
         },
     );
 
-    loadParametricPartTable(
-        "#parametric-part-table",
-        "{% url 'api-part-list' %}",
-        {
-            params: {
-                {% if category %}category: {{ category.id }},
-                {% else %}category: "null",
-                {% endif %}
-            },
-        },
-    );
-
 {% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/category_parametric.html b/InvenTree/part/templates/part/category_parametric.html
index 7139a539b0..5c99fe391b 100644
--- a/InvenTree/part/templates/part/category_parametric.html
+++ b/InvenTree/part/templates/part/category_parametric.html
@@ -2,7 +2,7 @@
 {% load static %}
 {% load i18n %}
 
-{% block part_list %}
+{% block category_tables %}
 
 {% include 'part/category_tabs.html' with tab='parametric-table' %}
 
@@ -10,3 +10,22 @@
 </table>
 
 {% endblock %}
+
+{% block js_ready %}
+{{ block.super }}
+
+    loadParametricPartTable(
+        "#parametric-part-table",
+        "{% url 'api-part-list' %}",
+        {
+            params: {
+                {% if category %}category: {{ category.id }},
+                {% else %}category: "null",
+                {% endif %}
+            },
+            headers: {{ parameters|safe }},
+            name: 'parametric',
+        },
+    );
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/templates/part/category_partlist.html b/InvenTree/part/templates/part/category_partlist.html
index bf4aef52a8..302fdd3a02 100644
--- a/InvenTree/part/templates/part/category_partlist.html
+++ b/InvenTree/part/templates/part/category_partlist.html
@@ -2,7 +2,7 @@
 {% load static %}
 {% load i18n %}
 
-{% block part_list %}
+{% block category_tables %}
 
 {% include 'part/category_tabs.html' with tab='part-list' %}
 
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 88d15c1b5b..32cd1b0615 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -77,8 +77,8 @@ part_category_urls = [
     url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
     url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
 
-    url(r'^parametric/?', views.CategoryDetail.as_view(template_name='part/category_parametric.html'), name='category-parametric'),
-    url(r'^.*$', views.CategoryDetail.as_view(template_name='part/category_partlist.html'), name='category-detail'),
+    url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'),
+    url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
 ]
 
 part_bom_urls = [
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 3c94afc5a5..80e5beadf8 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -1889,6 +1889,21 @@ class CategoryDetail(DetailView):
         return context
 
 
+class CategoryParametric(CategoryDetail):
+    """ Parametric view for PartCategory """
+    template_name = 'part/category_parametric.html'
+
+    def get_context_data(self, **kwargs):
+
+        context = super(CategoryParametric, self).get_context_data(**kwargs).copy()
+
+        category = kwargs['object']
+        context['parameters'] = category.get_unique_parameters()
+        print(context)
+
+        return context
+
+
 class CategoryEdit(AjaxUpdateView):
     """ Update view to edit a PartCategory """
     model = PartCategory
diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index 0b32ef61b9..4d8d09dc9c 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -171,13 +171,10 @@ function loadParametricPartTable(table, url, options={}) {
      *  - url: Base URL for API query
      */
 
-    // Ensure category detail is included
-    options.params['category_detail'] = true;
-
     var params = options.params || {};
     console.log(params)
 
-    var filters = {};
+    var filters = loadTableFilters("parts");
     for (var key in params) {
         filters[key] = params[key];
     }
@@ -187,19 +184,88 @@ function loadParametricPartTable(table, url, options={}) {
         {
             field: 'pk',
             title: 'ID',
-            visible: true,
-            switchable: true,
+            visible: false,
+            switchable: false,
             searchable: false,
         }
     ];
 
+    columns.push({
+        field: 'IPN',
+        title: 'IPN',
+        sortable: true,
+    }),
+
+    columns.push({
+        field: 'name',
+        title: '{% trans 'Part' %}',
+        sortable: true,
+        formatter: function(value, row, index, field) {
+
+            var name = '';
+
+            if (row.IPN) {
+                name += row.IPN;
+                name += ' | ';
+            }
+
+            name += value;
+
+            if (row.revision) {
+                name += ' | ';
+                name += row.revision;
+            }
+
+            if (row.is_template) {
+                name = '<i>' + name + '</i>';
+            }
+
+            var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
+            
+            if (row.is_template) {
+                display += `<span class='fas fa-clone label-right' title='{% trans "Template part" %}'></span>`;
+            }
+            
+            if (row.assembly) {
+                display += `<span class='fas fa-tools label-right' title='{% trans "Assembled part" %}'></span>`;
+            }
+
+            if (row.starred) {
+                display += `<span class='fas fa-star label-right' title='{% trans "Starred part" %}'></span>`;
+            }
+
+            if (row.salable) {
+                display += `<span class='fas fa-dollar-sign label-right' title='{% trans "Salable part" %}'></span>`;
+            }
+
+            /*
+            if (row.component) {
+                display = display + `<span class='fas fa-cogs label-right' title='Component part'></span>`;
+            }
+            */
+            
+            if (!row.active) {
+                display += `<span class='label label-warning label-right'>{% trans "Inactive" %}</span>`; 
+            }
+            return display; 
+        }
+    });
+
+    for (header of options.headers) {
+        columns.push({
+            field: header,
+            title: header,
+            sortable: true,
+        })
+    }
+
     $(table).inventreeTable({
         url: url,
         sortName: 'pk',
         method: 'get',
         queryParams: filters,
         groupBy: false,
-        name: options.name || 'part',
+        name: options.name || 'parametric',
         original: params,
         formatNoMatches: function() { return "{% trans "No parts found" %}"; },
         columns: columns,

From 40d8a07acc8a63a505908110c961531069049e45 Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Tue, 29 Sep 2020 16:49:53 -0500
Subject: [PATCH 03/77] Now loading data! Still need to be bonified

---
 InvenTree/part/models.py                      | 32 ++++--
 .../templates/part/category_parametric.html   | 14 +--
 InvenTree/part/views.py                       |  6 +-
 InvenTree/templates/js/part.html              | 98 +++----------------
 4 files changed, 47 insertions(+), 103 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 707b0b948c..41fcf0bfce 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -112,18 +112,38 @@ class PartCategory(InvenTreeTree):
         return self.partcount() > 0
 
     def get_unique_parameters(self, cascade=True):
-        """ Get all parameters for all parts from this category """
-        parameters = []
+        """ Get all unique parameter names for all parts from this category """
+        unique_parameters_names = []
 
         parts = self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template')
 
         for part in parts:
             for parameter in part.parameters.all():
-                template_name = parameter.template.name
-                if template_name not in parameters:
-                    parameters.append(template_name)
+                parameter_name = parameter.template.name
+                if parameter_name not in unique_parameters_names:
+                    unique_parameters_names.append(parameter_name)
 
-        return parameters
+        return unique_parameters_names
+
+    def get_parts_parameters(self, cascade=True):
+        """ Get all parameter names and values for all parts from this category """
+        category_parameters = []
+
+        parts = self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template')
+
+        for part in parts:
+            part_parameters = {
+                'IPN': part.IPN,
+                'Name': part.name,
+            }
+            for parameter in part.parameters.all():
+                parameter_name = parameter.template.name
+                parameter_value = parameter.data
+                part_parameters[parameter_name] = parameter_value
+
+            category_parameters.append(part_parameters)
+
+        return category_parameters
 
 
 @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
diff --git a/InvenTree/part/templates/part/category_parametric.html b/InvenTree/part/templates/part/category_parametric.html
index 5c99fe391b..4643dfee8a 100644
--- a/InvenTree/part/templates/part/category_parametric.html
+++ b/InvenTree/part/templates/part/category_parametric.html
@@ -16,16 +16,10 @@
 
     loadParametricPartTable(
         "#parametric-part-table",
-        "{% url 'api-part-list' %}",
-        {
-            params: {
-                {% if category %}category: {{ category.id }},
-                {% else %}category: "null",
-                {% endif %}
-            },
-            headers: {{ parameters|safe }},
-            name: 'parametric',
-        },
+        { 
+        	headers: {{ headers|safe }},
+        	data: {{ parameters|safe }},
+        }
     );
 
 {% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 80e5beadf8..0f0f21b9fc 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -1898,8 +1898,10 @@ class CategoryParametric(CategoryDetail):
         context = super(CategoryParametric, self).get_context_data(**kwargs).copy()
 
         category = kwargs['object']
-        context['parameters'] = category.get_unique_parameters()
-        print(context)
+        context['headers'] = category.get_unique_parameters()
+        context['headers'].append('IPN')
+        context['headers'].append('Name')
+        context['parameters'] = category.get_parts_parameters()
 
         return context
 
diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index 4d8d09dc9c..5ba46b4f31 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -163,95 +163,22 @@ function loadSimplePartTable(table, url, options={}) {
 }
 
 
-function loadParametricPartTable(table, url, options={}) {
+function loadParametricPartTable(table, options={}) {
     /* Load parametric part data into specified table.
      * 
      * Args:
      *  - table: HTML reference to the table
-     *  - url: Base URL for API query
+     *  - data: Parameters data
      */
 
-    var params = options.params || {};
-    console.log(params)
+    var table_headers = options.headers
+    var table_data = options.data
+/*    console.log(table_headers)
+    console.log(table_data)*/
 
-    var filters = loadTableFilters("parts");
-    for (var key in params) {
-        filters[key] = params[key];
-    }
-    console.log(filters)
+    var columns = [];
 
-    var columns = [
-        {
-            field: 'pk',
-            title: 'ID',
-            visible: false,
-            switchable: false,
-            searchable: false,
-        }
-    ];
-
-    columns.push({
-        field: 'IPN',
-        title: 'IPN',
-        sortable: true,
-    }),
-
-    columns.push({
-        field: 'name',
-        title: '{% trans 'Part' %}',
-        sortable: true,
-        formatter: function(value, row, index, field) {
-
-            var name = '';
-
-            if (row.IPN) {
-                name += row.IPN;
-                name += ' | ';
-            }
-
-            name += value;
-
-            if (row.revision) {
-                name += ' | ';
-                name += row.revision;
-            }
-
-            if (row.is_template) {
-                name = '<i>' + name + '</i>';
-            }
-
-            var display = imageHoverIcon(row.thumbnail) + renderLink(name, '/part/' + row.pk + '/');
-            
-            if (row.is_template) {
-                display += `<span class='fas fa-clone label-right' title='{% trans "Template part" %}'></span>`;
-            }
-            
-            if (row.assembly) {
-                display += `<span class='fas fa-tools label-right' title='{% trans "Assembled part" %}'></span>`;
-            }
-
-            if (row.starred) {
-                display += `<span class='fas fa-star label-right' title='{% trans "Starred part" %}'></span>`;
-            }
-
-            if (row.salable) {
-                display += `<span class='fas fa-dollar-sign label-right' title='{% trans "Salable part" %}'></span>`;
-            }
-
-            /*
-            if (row.component) {
-                display = display + `<span class='fas fa-cogs label-right' title='Component part'></span>`;
-            }
-            */
-            
-            if (!row.active) {
-                display += `<span class='label label-warning label-right'>{% trans "Inactive" %}</span>`; 
-            }
-            return display; 
-        }
-    });
-
-    for (header of options.headers) {
+    for (header of table_headers) {
         columns.push({
             field: header,
             title: header,
@@ -260,16 +187,17 @@ function loadParametricPartTable(table, url, options={}) {
     }
 
     $(table).inventreeTable({
-        url: url,
-        sortName: 'pk',
+/*        url: url,*/
+        sortName: 'name',
         method: 'get',
-        queryParams: filters,
+        queryParams: table_headers,
         groupBy: false,
         name: options.name || 'parametric',
-        original: params,
+/*        original: params,*/
         formatNoMatches: function() { return "{% trans "No parts found" %}"; },
         columns: columns,
         showColumns: true,
+        data: table_data,
     });
 }
 

From 756f3ddb0fed6aacbe5283c27a4d9844a9bf347e Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 1 Oct 2020 00:25:24 +1000
Subject: [PATCH 04/77] Hide main elements of navigation bar based on user
 permissions

---
 InvenTree/templates/navbar.html | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index cfadad977c..57a902b755 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -15,9 +15,16 @@
     </div>
     <div class="navbar-collapse collapse">
       <ul class="nav navbar-nav">
+        {% if perms.part.view_part or perms.part.view_partcategory %}
         <li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
+        {% endif %}
+        {% if perms.stock.view_stockitem or perms.part.view_stocklocation %}
         <li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
+        {% endif %}
+        {% if perms.build %}
         <li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
+        {% endif %}
+        {% if perms.order.view_purchaseorder %}
         <li class='nav navbar-nav'>
           <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
           <ul class='dropdown-menu'>
@@ -26,6 +33,8 @@
             <li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li>
           </ul>
         </li>
+        {% endif %}
+        {% if perms.order.view_salesorder %}
         <li class='nav navbar-nav'>
           <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
           <ul class='dropdown-menu'>
@@ -33,6 +42,7 @@
             <li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li>
           </ul>
         </li>
+        {% endif %}
       </ul>
       <ul class="nav navbar-nav navbar-right">
           {% include "search_form.html" %}

From b7d25a75c48b4f873771a9654a3d54ba7aaef92b Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Thu, 1 Oct 2020 10:03:49 -0500
Subject: [PATCH 05/77] Hide part toolbar, nicer part representation, improved
 parameters prefetching

---
 InvenTree/part/models.py                      | 32 +++++++++++----
 .../templates/part/category_parametric.html   |  6 +++
 InvenTree/part/views.py                       | 23 ++++++++---
 InvenTree/templates/js/part.html              | 40 +++++++++++++++----
 4 files changed, 82 insertions(+), 19 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 41fcf0bfce..272afacd0e 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -111,11 +111,20 @@ class PartCategory(InvenTreeTree):
         """ True if there are any parts in this category """
         return self.partcount() > 0
 
-    def get_unique_parameters(self, cascade=True):
+    def prefetch_parts_parameters(self, cascade=True):
+        """ Prefectch parts parameters """
+
+        return self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template')
+
+    def get_unique_parameters(self, cascade=True, prefetch=None):
         """ Get all unique parameter names for all parts from this category """
+
         unique_parameters_names = []
 
-        parts = self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template')
+        if prefetch:
+            parts = prefetch
+        else:
+            parts = self.prefetch_parts_parameters(cascade=cascade)
 
         for part in parts:
             for parameter in part.parameters.all():
@@ -123,19 +132,28 @@ class PartCategory(InvenTreeTree):
                 if parameter_name not in unique_parameters_names:
                     unique_parameters_names.append(parameter_name)
 
-        return unique_parameters_names
+        return sorted(unique_parameters_names)
 
-    def get_parts_parameters(self, cascade=True):
+    def get_parts_parameters(self, cascade=True, prefetch=None):
         """ Get all parameter names and values for all parts from this category """
+
         category_parameters = []
 
-        parts = self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template')
+        if prefetch:
+            parts = prefetch
+        else:
+            parts = self.prefetch_parts_parameters(cascade=cascade)
 
         for part in parts:
             part_parameters = {
-                'IPN': part.IPN,
-                'Name': part.name,
+                'pk': part.pk,
+                'name': part.name,
+                'description': part.description,
             }
+            # Add IPN only if it exists
+            if part.IPN:
+                part_parameters['IPN'] = part.IPN
+
             for parameter in part.parameters.all():
                 parameter_name = parameter.template.name
                 parameter_value = parameter.data
diff --git a/InvenTree/part/templates/part/category_parametric.html b/InvenTree/part/templates/part/category_parametric.html
index 4643dfee8a..3cf4f37a20 100644
--- a/InvenTree/part/templates/part/category_parametric.html
+++ b/InvenTree/part/templates/part/category_parametric.html
@@ -13,6 +13,12 @@
 
 {% block js_ready %}
 {{ block.super }}
+	
+	/* Hide Button Toolbar */
+	window.onload = function hideButtonToolbar() {
+		var toolbar = document.getElementById("button-toolbar");
+		toolbar.style.display = "none";
+	};
 
     loadParametricPartTable(
         "#parametric-part-table",
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 0f0f21b9fc..dc8d07f5cd 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -1872,6 +1872,7 @@ class PartParameterDelete(AjaxDeleteView):
 
 class CategoryDetail(DetailView):
     """ Detail view for PartCategory """
+
     model = PartCategory
     context_object_name = 'category'
     queryset = PartCategory.objects.all().prefetch_related('children')
@@ -1891,17 +1892,29 @@ class CategoryDetail(DetailView):
 
 class CategoryParametric(CategoryDetail):
     """ Parametric view for PartCategory """
+
     template_name = 'part/category_parametric.html'
 
     def get_context_data(self, **kwargs):
 
         context = super(CategoryParametric, self).get_context_data(**kwargs).copy()
 
-        category = kwargs['object']
-        context['headers'] = category.get_unique_parameters()
-        context['headers'].append('IPN')
-        context['headers'].append('Name')
-        context['parameters'] = category.get_parts_parameters()
+        # Get current category
+        category = kwargs.get('object', None)
+
+        if category:
+            cascade = kwargs.get('cascade', True)
+            # Prefetch parts parameters
+            parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
+            # Get table headers (unique parameters names)
+            context['headers'] = category.get_unique_parameters(cascade=cascade,
+                                                                prefetch=parts_parameters)
+            # Insert part information
+            context['headers'].insert(0, 'description')
+            context['headers'].insert(0, 'part')
+            # Get parameters data
+            context['parameters'] = category.get_parts_parameters(cascade=cascade,
+                                                                  prefetch=parts_parameters)
 
         return context
 
diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index 5ba46b4f31..59406e7672 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -179,17 +179,43 @@ function loadParametricPartTable(table, options={}) {
     var columns = [];
 
     for (header of table_headers) {
-        columns.push({
-            field: header,
-            title: header,
-            sortable: true,
-        })
+        if (header === 'part') {
+            columns.push({
+                field: 'part',
+                title: '{% trans 'Part' %}',
+                sortable: true,
+                formatter: function(value, row, index, field) {
+
+                    var name = '';
+
+                    if (row.IPN) {
+                        name += row.IPN + ' | ' + row.name;
+                    } else {
+                        name += row.name;
+                    }
+                    
+                    return renderLink(name, '/part/' + row.pk + '/'); 
+                }
+            });
+        } else if (header === 'description') {
+            columns.push({
+                field: header,
+                title: '{% trans 'Description' %}',
+                sortable: true,
+            });
+        } else {
+            columns.push({
+                field: header,
+                title: header,
+                sortable: true,
+            });
+        }
     }
 
     $(table).inventreeTable({
 /*        url: url,*/
-        sortName: 'name',
-        method: 'get',
+        sortName: 'part',
+/*        method: 'get',*/
         queryParams: table_headers,
         groupBy: false,
         name: options.name || 'parametric',

From 15e1c0579125032bc4990915cc29bfa853a5a40f Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Thu, 1 Oct 2020 11:05:08 -0500
Subject: [PATCH 06/77] Fixed 'Part' column sorting

---
 InvenTree/templates/js/part.html | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index 59406e7672..bbdca8f8c4 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -168,7 +168,8 @@ function loadParametricPartTable(table, options={}) {
      * 
      * Args:
      *  - table: HTML reference to the table
-     *  - data: Parameters data
+     *  - table_headers: Table headers/columns
+     *  - table_data: Parameters data
      */
 
     var table_headers = options.headers
@@ -181,9 +182,10 @@ function loadParametricPartTable(table, options={}) {
     for (header of table_headers) {
         if (header === 'part') {
             columns.push({
-                field: 'part',
+                field: header,
                 title: '{% trans 'Part' %}',
                 sortable: true,
+                sortName: 'name',
                 formatter: function(value, row, index, field) {
 
                     var name = '';

From 9d3d9a190b390e4d4938b7d457366f9bea9bf035 Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Thu, 1 Oct 2020 12:10:35 -0500
Subject: [PATCH 07/77] Added bootstrap table 'filter-control' extension to use
 in parametric tables

---
 .../css/bootstrap-table-filter-control.css    |   13 +
 .../bootstrap-table-filter-control.js         | 3021 +++++++++++++++++
 .../script/bootstrap/filter-control-utils.js  | 2361 +++++++++++++
 InvenTree/templates/base.html                 |    3 +
 InvenTree/templates/js/part.html              |    3 +
 5 files changed, 5401 insertions(+)
 create mode 100644 InvenTree/InvenTree/static/css/bootstrap-table-filter-control.css
 create mode 100644 InvenTree/InvenTree/static/script/bootstrap/bootstrap-table-filter-control.js
 create mode 100644 InvenTree/InvenTree/static/script/bootstrap/filter-control-utils.js

diff --git a/InvenTree/InvenTree/static/css/bootstrap-table-filter-control.css b/InvenTree/InvenTree/static/css/bootstrap-table-filter-control.css
new file mode 100644
index 0000000000..012d6bd897
--- /dev/null
+++ b/InvenTree/InvenTree/static/css/bootstrap-table-filter-control.css
@@ -0,0 +1,13 @@
+@charset "UTF-8";
+/**
+ * @author: Dennis Hernández
+ * @webSite: http://djhvscf.github.io/Blog
+ * @version: v2.1.1
+ */
+.no-filter-control {
+  height: 34px;
+}
+
+.filter-control {
+  margin: 0 2px 2px 2px;
+}
diff --git a/InvenTree/InvenTree/static/script/bootstrap/bootstrap-table-filter-control.js b/InvenTree/InvenTree/static/script/bootstrap/bootstrap-table-filter-control.js
new file mode 100644
index 0000000000..f683c92b03
--- /dev/null
+++ b/InvenTree/InvenTree/static/script/bootstrap/bootstrap-table-filter-control.js
@@ -0,0 +1,3021 @@
+(function (global, factory) {
+	typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('jquery')) :
+	typeof define === 'function' && define.amd ? define(['jquery'], factory) :
+	(global = global || self, factory(global.jQuery));
+}(this, (function ($) { 'use strict';
+
+	$ = $ && Object.prototype.hasOwnProperty.call($, 'default') ? $['default'] : $;
+
+	var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
+
+	function createCommonjsModule(fn, module) {
+		return module = { exports: {} }, fn(module, module.exports), module.exports;
+	}
+
+	var check = function (it) {
+	  return it && it.Math == Math && it;
+	};
+
+	// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028
+	var global_1 =
+	  // eslint-disable-next-line no-undef
+	  check(typeof globalThis == 'object' && globalThis) ||
+	  check(typeof window == 'object' && window) ||
+	  check(typeof self == 'object' && self) ||
+	  check(typeof commonjsGlobal == 'object' && commonjsGlobal) ||
+	  // eslint-disable-next-line no-new-func
+	  Function('return this')();
+
+	var fails = function (exec) {
+	  try {
+	    return !!exec();
+	  } catch (error) {
+	    return true;
+	  }
+	};
+
+	// Thank's IE8 for his funny defineProperty
+	var descriptors = !fails(function () {
+	  return Object.defineProperty({}, 'a', { get: function () { return 7; } }).a != 7;
+	});
+
+	var nativePropertyIsEnumerable = {}.propertyIsEnumerable;
+	var getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
+
+	// Nashorn ~ JDK8 bug
+	var NASHORN_BUG = getOwnPropertyDescriptor && !nativePropertyIsEnumerable.call({ 1: 2 }, 1);
+
+	// `Object.prototype.propertyIsEnumerable` method implementation
+	// https://tc39.github.io/ecma262/#sec-object.prototype.propertyisenumerable
+	var f = NASHORN_BUG ? function propertyIsEnumerable(V) {
+	  var descriptor = getOwnPropertyDescriptor(this, V);
+	  return !!descriptor && descriptor.enumerable;
+	} : nativePropertyIsEnumerable;
+
+	var objectPropertyIsEnumerable = {
+		f: f
+	};
+
+	var createPropertyDescriptor = function (bitmap, value) {
+	  return {
+	    enumerable: !(bitmap & 1),
+	    configurable: !(bitmap & 2),
+	    writable: !(bitmap & 4),
+	    value: value
+	  };
+	};
+
+	var toString = {}.toString;
+
+	var classofRaw = function (it) {
+	  return toString.call(it).slice(8, -1);
+	};
+
+	var split = ''.split;
+
+	// fallback for non-array-like ES3 and non-enumerable old V8 strings
+	var indexedObject = fails(function () {
+	  // throws an error in rhino, see https://github.com/mozilla/rhino/issues/346
+	  // eslint-disable-next-line no-prototype-builtins
+	  return !Object('z').propertyIsEnumerable(0);
+	}) ? function (it) {
+	  return classofRaw(it) == 'String' ? split.call(it, '') : Object(it);
+	} : Object;
+
+	// `RequireObjectCoercible` abstract operation
+	// https://tc39.github.io/ecma262/#sec-requireobjectcoercible
+	var requireObjectCoercible = function (it) {
+	  if (it == undefined) throw TypeError("Can't call method on " + it);
+	  return it;
+	};
+
+	// toObject with fallback for non-array-like ES3 strings
+
+
+
+	var toIndexedObject = function (it) {
+	  return indexedObject(requireObjectCoercible(it));
+	};
+
+	var isObject = function (it) {
+	  return typeof it === 'object' ? it !== null : typeof it === 'function';
+	};
+
+	// `ToPrimitive` abstract operation
+	// https://tc39.github.io/ecma262/#sec-toprimitive
+	// instead of the ES6 spec version, we didn't implement @@toPrimitive case
+	// and the second argument - flag - preferred type is a string
+	var toPrimitive = function (input, PREFERRED_STRING) {
+	  if (!isObject(input)) return input;
+	  var fn, val;
+	  if (PREFERRED_STRING && typeof (fn = input.toString) == 'function' && !isObject(val = fn.call(input))) return val;
+	  if (typeof (fn = input.valueOf) == 'function' && !isObject(val = fn.call(input))) return val;
+	  if (!PREFERRED_STRING && typeof (fn = input.toString) == 'function' && !isObject(val = fn.call(input))) return val;
+	  throw TypeError("Can't convert object to primitive value");
+	};
+
+	var hasOwnProperty = {}.hasOwnProperty;
+
+	var has = function (it, key) {
+	  return hasOwnProperty.call(it, key);
+	};
+
+	var document$1 = global_1.document;
+	// typeof document.createElement is 'object' in old IE
+	var EXISTS = isObject(document$1) && isObject(document$1.createElement);
+
+	var documentCreateElement = function (it) {
+	  return EXISTS ? document$1.createElement(it) : {};
+	};
+
+	// Thank's IE8 for his funny defineProperty
+	var ie8DomDefine = !descriptors && !fails(function () {
+	  return Object.defineProperty(documentCreateElement('div'), 'a', {
+	    get: function () { return 7; }
+	  }).a != 7;
+	});
+
+	var nativeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
+
+	// `Object.getOwnPropertyDescriptor` method
+	// https://tc39.github.io/ecma262/#sec-object.getownpropertydescriptor
+	var f$1 = descriptors ? nativeGetOwnPropertyDescriptor : function getOwnPropertyDescriptor(O, P) {
+	  O = toIndexedObject(O);
+	  P = toPrimitive(P, true);
+	  if (ie8DomDefine) try {
+	    return nativeGetOwnPropertyDescriptor(O, P);
+	  } catch (error) { /* empty */ }
+	  if (has(O, P)) return createPropertyDescriptor(!objectPropertyIsEnumerable.f.call(O, P), O[P]);
+	};
+
+	var objectGetOwnPropertyDescriptor = {
+		f: f$1
+	};
+
+	var anObject = function (it) {
+	  if (!isObject(it)) {
+	    throw TypeError(String(it) + ' is not an object');
+	  } return it;
+	};
+
+	var nativeDefineProperty = Object.defineProperty;
+
+	// `Object.defineProperty` method
+	// https://tc39.github.io/ecma262/#sec-object.defineproperty
+	var f$2 = descriptors ? nativeDefineProperty : function defineProperty(O, P, Attributes) {
+	  anObject(O);
+	  P = toPrimitive(P, true);
+	  anObject(Attributes);
+	  if (ie8DomDefine) try {
+	    return nativeDefineProperty(O, P, Attributes);
+	  } catch (error) { /* empty */ }
+	  if ('get' in Attributes || 'set' in Attributes) throw TypeError('Accessors not supported');
+	  if ('value' in Attributes) O[P] = Attributes.value;
+	  return O;
+	};
+
+	var objectDefineProperty = {
+		f: f$2
+	};
+
+	var createNonEnumerableProperty = descriptors ? function (object, key, value) {
+	  return objectDefineProperty.f(object, key, createPropertyDescriptor(1, value));
+	} : function (object, key, value) {
+	  object[key] = value;
+	  return object;
+	};
+
+	var setGlobal = function (key, value) {
+	  try {
+	    createNonEnumerableProperty(global_1, key, value);
+	  } catch (error) {
+	    global_1[key] = value;
+	  } return value;
+	};
+
+	var SHARED = '__core-js_shared__';
+	var store = global_1[SHARED] || setGlobal(SHARED, {});
+
+	var sharedStore = store;
+
+	var functionToString = Function.toString;
+
+	// this helper broken in `3.4.1-3.4.4`, so we can't use `shared` helper
+	if (typeof sharedStore.inspectSource != 'function') {
+	  sharedStore.inspectSource = function (it) {
+	    return functionToString.call(it);
+	  };
+	}
+
+	var inspectSource = sharedStore.inspectSource;
+
+	var WeakMap = global_1.WeakMap;
+
+	var nativeWeakMap = typeof WeakMap === 'function' && /native code/.test(inspectSource(WeakMap));
+
+	var shared = createCommonjsModule(function (module) {
+	(module.exports = function (key, value) {
+	  return sharedStore[key] || (sharedStore[key] = value !== undefined ? value : {});
+	})('versions', []).push({
+	  version: '3.6.0',
+	  mode:  'global',
+	  copyright: '© 2019 Denis Pushkarev (zloirock.ru)'
+	});
+	});
+
+	var id = 0;
+	var postfix = Math.random();
+
+	var uid = function (key) {
+	  return 'Symbol(' + String(key === undefined ? '' : key) + ')_' + (++id + postfix).toString(36);
+	};
+
+	var keys = shared('keys');
+
+	var sharedKey = function (key) {
+	  return keys[key] || (keys[key] = uid(key));
+	};
+
+	var hiddenKeys = {};
+
+	var WeakMap$1 = global_1.WeakMap;
+	var set, get, has$1;
+
+	var enforce = function (it) {
+	  return has$1(it) ? get(it) : set(it, {});
+	};
+
+	var getterFor = function (TYPE) {
+	  return function (it) {
+	    var state;
+	    if (!isObject(it) || (state = get(it)).type !== TYPE) {
+	      throw TypeError('Incompatible receiver, ' + TYPE + ' required');
+	    } return state;
+	  };
+	};
+
+	if (nativeWeakMap) {
+	  var store$1 = new WeakMap$1();
+	  var wmget = store$1.get;
+	  var wmhas = store$1.has;
+	  var wmset = store$1.set;
+	  set = function (it, metadata) {
+	    wmset.call(store$1, it, metadata);
+	    return metadata;
+	  };
+	  get = function (it) {
+	    return wmget.call(store$1, it) || {};
+	  };
+	  has$1 = function (it) {
+	    return wmhas.call(store$1, it);
+	  };
+	} else {
+	  var STATE = sharedKey('state');
+	  hiddenKeys[STATE] = true;
+	  set = function (it, metadata) {
+	    createNonEnumerableProperty(it, STATE, metadata);
+	    return metadata;
+	  };
+	  get = function (it) {
+	    return has(it, STATE) ? it[STATE] : {};
+	  };
+	  has$1 = function (it) {
+	    return has(it, STATE);
+	  };
+	}
+
+	var internalState = {
+	  set: set,
+	  get: get,
+	  has: has$1,
+	  enforce: enforce,
+	  getterFor: getterFor
+	};
+
+	var redefine = createCommonjsModule(function (module) {
+	var getInternalState = internalState.get;
+	var enforceInternalState = internalState.enforce;
+	var TEMPLATE = String(String).split('String');
+
+	(module.exports = function (O, key, value, options) {
+	  var unsafe = options ? !!options.unsafe : false;
+	  var simple = options ? !!options.enumerable : false;
+	  var noTargetGet = options ? !!options.noTargetGet : false;
+	  if (typeof value == 'function') {
+	    if (typeof key == 'string' && !has(value, 'name')) createNonEnumerableProperty(value, 'name', key);
+	    enforceInternalState(value).source = TEMPLATE.join(typeof key == 'string' ? key : '');
+	  }
+	  if (O === global_1) {
+	    if (simple) O[key] = value;
+	    else setGlobal(key, value);
+	    return;
+	  } else if (!unsafe) {
+	    delete O[key];
+	  } else if (!noTargetGet && O[key]) {
+	    simple = true;
+	  }
+	  if (simple) O[key] = value;
+	  else createNonEnumerableProperty(O, key, value);
+	// add fake Function#toString for correct work wrapped methods / constructors with methods like LoDash isNative
+	})(Function.prototype, 'toString', function toString() {
+	  return typeof this == 'function' && getInternalState(this).source || inspectSource(this);
+	});
+	});
+
+	var path = global_1;
+
+	var aFunction = function (variable) {
+	  return typeof variable == 'function' ? variable : undefined;
+	};
+
+	var getBuiltIn = function (namespace, method) {
+	  return arguments.length < 2 ? aFunction(path[namespace]) || aFunction(global_1[namespace])
+	    : path[namespace] && path[namespace][method] || global_1[namespace] && global_1[namespace][method];
+	};
+
+	var ceil = Math.ceil;
+	var floor = Math.floor;
+
+	// `ToInteger` abstract operation
+	// https://tc39.github.io/ecma262/#sec-tointeger
+	var toInteger = function (argument) {
+	  return isNaN(argument = +argument) ? 0 : (argument > 0 ? floor : ceil)(argument);
+	};
+
+	var min = Math.min;
+
+	// `ToLength` abstract operation
+	// https://tc39.github.io/ecma262/#sec-tolength
+	var toLength = function (argument) {
+	  return argument > 0 ? min(toInteger(argument), 0x1FFFFFFFFFFFFF) : 0; // 2 ** 53 - 1 == 9007199254740991
+	};
+
+	var max = Math.max;
+	var min$1 = Math.min;
+
+	// Helper for a popular repeating case of the spec:
+	// Let integer be ? ToInteger(index).
+	// If integer < 0, let result be max((length + integer), 0); else let result be min(integer, length).
+	var toAbsoluteIndex = function (index, length) {
+	  var integer = toInteger(index);
+	  return integer < 0 ? max(integer + length, 0) : min$1(integer, length);
+	};
+
+	// `Array.prototype.{ indexOf, includes }` methods implementation
+	var createMethod = function (IS_INCLUDES) {
+	  return function ($this, el, fromIndex) {
+	    var O = toIndexedObject($this);
+	    var length = toLength(O.length);
+	    var index = toAbsoluteIndex(fromIndex, length);
+	    var value;
+	    // Array#includes uses SameValueZero equality algorithm
+	    // eslint-disable-next-line no-self-compare
+	    if (IS_INCLUDES && el != el) while (length > index) {
+	      value = O[index++];
+	      // eslint-disable-next-line no-self-compare
+	      if (value != value) return true;
+	    // Array#indexOf ignores holes, Array#includes - not
+	    } else for (;length > index; index++) {
+	      if ((IS_INCLUDES || index in O) && O[index] === el) return IS_INCLUDES || index || 0;
+	    } return !IS_INCLUDES && -1;
+	  };
+	};
+
+	var arrayIncludes = {
+	  // `Array.prototype.includes` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.includes
+	  includes: createMethod(true),
+	  // `Array.prototype.indexOf` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.indexof
+	  indexOf: createMethod(false)
+	};
+
+	var indexOf = arrayIncludes.indexOf;
+
+
+	var objectKeysInternal = function (object, names) {
+	  var O = toIndexedObject(object);
+	  var i = 0;
+	  var result = [];
+	  var key;
+	  for (key in O) !has(hiddenKeys, key) && has(O, key) && result.push(key);
+	  // Don't enum bug & hidden keys
+	  while (names.length > i) if (has(O, key = names[i++])) {
+	    ~indexOf(result, key) || result.push(key);
+	  }
+	  return result;
+	};
+
+	// IE8- don't enum bug keys
+	var enumBugKeys = [
+	  'constructor',
+	  'hasOwnProperty',
+	  'isPrototypeOf',
+	  'propertyIsEnumerable',
+	  'toLocaleString',
+	  'toString',
+	  'valueOf'
+	];
+
+	var hiddenKeys$1 = enumBugKeys.concat('length', 'prototype');
+
+	// `Object.getOwnPropertyNames` method
+	// https://tc39.github.io/ecma262/#sec-object.getownpropertynames
+	var f$3 = Object.getOwnPropertyNames || function getOwnPropertyNames(O) {
+	  return objectKeysInternal(O, hiddenKeys$1);
+	};
+
+	var objectGetOwnPropertyNames = {
+		f: f$3
+	};
+
+	var f$4 = Object.getOwnPropertySymbols;
+
+	var objectGetOwnPropertySymbols = {
+		f: f$4
+	};
+
+	// all object keys, includes non-enumerable and symbols
+	var ownKeys = getBuiltIn('Reflect', 'ownKeys') || function ownKeys(it) {
+	  var keys = objectGetOwnPropertyNames.f(anObject(it));
+	  var getOwnPropertySymbols = objectGetOwnPropertySymbols.f;
+	  return getOwnPropertySymbols ? keys.concat(getOwnPropertySymbols(it)) : keys;
+	};
+
+	var copyConstructorProperties = function (target, source) {
+	  var keys = ownKeys(source);
+	  var defineProperty = objectDefineProperty.f;
+	  var getOwnPropertyDescriptor = objectGetOwnPropertyDescriptor.f;
+	  for (var i = 0; i < keys.length; i++) {
+	    var key = keys[i];
+	    if (!has(target, key)) defineProperty(target, key, getOwnPropertyDescriptor(source, key));
+	  }
+	};
+
+	var replacement = /#|\.prototype\./;
+
+	var isForced = function (feature, detection) {
+	  var value = data[normalize(feature)];
+	  return value == POLYFILL ? true
+	    : value == NATIVE ? false
+	    : typeof detection == 'function' ? fails(detection)
+	    : !!detection;
+	};
+
+	var normalize = isForced.normalize = function (string) {
+	  return String(string).replace(replacement, '.').toLowerCase();
+	};
+
+	var data = isForced.data = {};
+	var NATIVE = isForced.NATIVE = 'N';
+	var POLYFILL = isForced.POLYFILL = 'P';
+
+	var isForced_1 = isForced;
+
+	var getOwnPropertyDescriptor$1 = objectGetOwnPropertyDescriptor.f;
+
+
+
+
+
+
+	/*
+	  options.target      - name of the target object
+	  options.global      - target is the global object
+	  options.stat        - export as static methods of target
+	  options.proto       - export as prototype methods of target
+	  options.real        - real prototype method for the `pure` version
+	  options.forced      - export even if the native feature is available
+	  options.bind        - bind methods to the target, required for the `pure` version
+	  options.wrap        - wrap constructors to preventing global pollution, required for the `pure` version
+	  options.unsafe      - use the simple assignment of property instead of delete + defineProperty
+	  options.sham        - add a flag to not completely full polyfills
+	  options.enumerable  - export as enumerable property
+	  options.noTargetGet - prevent calling a getter on target
+	*/
+	var _export = function (options, source) {
+	  var TARGET = options.target;
+	  var GLOBAL = options.global;
+	  var STATIC = options.stat;
+	  var FORCED, target, key, targetProperty, sourceProperty, descriptor;
+	  if (GLOBAL) {
+	    target = global_1;
+	  } else if (STATIC) {
+	    target = global_1[TARGET] || setGlobal(TARGET, {});
+	  } else {
+	    target = (global_1[TARGET] || {}).prototype;
+	  }
+	  if (target) for (key in source) {
+	    sourceProperty = source[key];
+	    if (options.noTargetGet) {
+	      descriptor = getOwnPropertyDescriptor$1(target, key);
+	      targetProperty = descriptor && descriptor.value;
+	    } else targetProperty = target[key];
+	    FORCED = isForced_1(GLOBAL ? key : TARGET + (STATIC ? '.' : '#') + key, options.forced);
+	    // contained in target
+	    if (!FORCED && targetProperty !== undefined) {
+	      if (typeof sourceProperty === typeof targetProperty) continue;
+	      copyConstructorProperties(sourceProperty, targetProperty);
+	    }
+	    // add a flag to not completely full polyfills
+	    if (options.sham || (targetProperty && targetProperty.sham)) {
+	      createNonEnumerableProperty(sourceProperty, 'sham', true);
+	    }
+	    // extend global
+	    redefine(target, key, sourceProperty, options);
+	  }
+	};
+
+	// `IsArray` abstract operation
+	// https://tc39.github.io/ecma262/#sec-isarray
+	var isArray = Array.isArray || function isArray(arg) {
+	  return classofRaw(arg) == 'Array';
+	};
+
+	// `ToObject` abstract operation
+	// https://tc39.github.io/ecma262/#sec-toobject
+	var toObject = function (argument) {
+	  return Object(requireObjectCoercible(argument));
+	};
+
+	var createProperty = function (object, key, value) {
+	  var propertyKey = toPrimitive(key);
+	  if (propertyKey in object) objectDefineProperty.f(object, propertyKey, createPropertyDescriptor(0, value));
+	  else object[propertyKey] = value;
+	};
+
+	var nativeSymbol = !!Object.getOwnPropertySymbols && !fails(function () {
+	  // Chrome 38 Symbol has incorrect toString conversion
+	  // eslint-disable-next-line no-undef
+	  return !String(Symbol());
+	});
+
+	var useSymbolAsUid = nativeSymbol
+	  // eslint-disable-next-line no-undef
+	  && !Symbol.sham
+	  // eslint-disable-next-line no-undef
+	  && typeof Symbol() == 'symbol';
+
+	var WellKnownSymbolsStore = shared('wks');
+	var Symbol$1 = global_1.Symbol;
+	var createWellKnownSymbol = useSymbolAsUid ? Symbol$1 : uid;
+
+	var wellKnownSymbol = function (name) {
+	  if (!has(WellKnownSymbolsStore, name)) {
+	    if (nativeSymbol && has(Symbol$1, name)) WellKnownSymbolsStore[name] = Symbol$1[name];
+	    else WellKnownSymbolsStore[name] = createWellKnownSymbol('Symbol.' + name);
+	  } return WellKnownSymbolsStore[name];
+	};
+
+	var SPECIES = wellKnownSymbol('species');
+
+	// `ArraySpeciesCreate` abstract operation
+	// https://tc39.github.io/ecma262/#sec-arrayspeciescreate
+	var arraySpeciesCreate = function (originalArray, length) {
+	  var C;
+	  if (isArray(originalArray)) {
+	    C = originalArray.constructor;
+	    // cross-realm fallback
+	    if (typeof C == 'function' && (C === Array || isArray(C.prototype))) C = undefined;
+	    else if (isObject(C)) {
+	      C = C[SPECIES];
+	      if (C === null) C = undefined;
+	    }
+	  } return new (C === undefined ? Array : C)(length === 0 ? 0 : length);
+	};
+
+	var userAgent = getBuiltIn('navigator', 'userAgent') || '';
+
+	var process = global_1.process;
+	var versions = process && process.versions;
+	var v8 = versions && versions.v8;
+	var match, version;
+
+	if (v8) {
+	  match = v8.split('.');
+	  version = match[0] + match[1];
+	} else if (userAgent) {
+	  match = userAgent.match(/Edge\/(\d+)/);
+	  if (!match || match[1] >= 74) {
+	    match = userAgent.match(/Chrome\/(\d+)/);
+	    if (match) version = match[1];
+	  }
+	}
+
+	var v8Version = version && +version;
+
+	var SPECIES$1 = wellKnownSymbol('species');
+
+	var arrayMethodHasSpeciesSupport = function (METHOD_NAME) {
+	  // We can't use this feature detection in V8 since it causes
+	  // deoptimization and serious performance degradation
+	  // https://github.com/zloirock/core-js/issues/677
+	  return v8Version >= 51 || !fails(function () {
+	    var array = [];
+	    var constructor = array.constructor = {};
+	    constructor[SPECIES$1] = function () {
+	      return { foo: 1 };
+	    };
+	    return array[METHOD_NAME](Boolean).foo !== 1;
+	  });
+	};
+
+	var IS_CONCAT_SPREADABLE = wellKnownSymbol('isConcatSpreadable');
+	var MAX_SAFE_INTEGER = 0x1FFFFFFFFFFFFF;
+	var MAXIMUM_ALLOWED_INDEX_EXCEEDED = 'Maximum allowed index exceeded';
+
+	// We can't use this feature detection in V8 since it causes
+	// deoptimization and serious performance degradation
+	// https://github.com/zloirock/core-js/issues/679
+	var IS_CONCAT_SPREADABLE_SUPPORT = v8Version >= 51 || !fails(function () {
+	  var array = [];
+	  array[IS_CONCAT_SPREADABLE] = false;
+	  return array.concat()[0] !== array;
+	});
+
+	var SPECIES_SUPPORT = arrayMethodHasSpeciesSupport('concat');
+
+	var isConcatSpreadable = function (O) {
+	  if (!isObject(O)) return false;
+	  var spreadable = O[IS_CONCAT_SPREADABLE];
+	  return spreadable !== undefined ? !!spreadable : isArray(O);
+	};
+
+	var FORCED = !IS_CONCAT_SPREADABLE_SUPPORT || !SPECIES_SUPPORT;
+
+	// `Array.prototype.concat` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.concat
+	// with adding support of @@isConcatSpreadable and @@species
+	_export({ target: 'Array', proto: true, forced: FORCED }, {
+	  concat: function concat(arg) { // eslint-disable-line no-unused-vars
+	    var O = toObject(this);
+	    var A = arraySpeciesCreate(O, 0);
+	    var n = 0;
+	    var i, k, length, len, E;
+	    for (i = -1, length = arguments.length; i < length; i++) {
+	      E = i === -1 ? O : arguments[i];
+	      if (isConcatSpreadable(E)) {
+	        len = toLength(E.length);
+	        if (n + len > MAX_SAFE_INTEGER) throw TypeError(MAXIMUM_ALLOWED_INDEX_EXCEEDED);
+	        for (k = 0; k < len; k++, n++) if (k in E) createProperty(A, n, E[k]);
+	      } else {
+	        if (n >= MAX_SAFE_INTEGER) throw TypeError(MAXIMUM_ALLOWED_INDEX_EXCEEDED);
+	        createProperty(A, n++, E);
+	      }
+	    }
+	    A.length = n;
+	    return A;
+	  }
+	});
+
+	var aFunction$1 = function (it) {
+	  if (typeof it != 'function') {
+	    throw TypeError(String(it) + ' is not a function');
+	  } return it;
+	};
+
+	// optional / simple context binding
+	var bindContext = function (fn, that, length) {
+	  aFunction$1(fn);
+	  if (that === undefined) return fn;
+	  switch (length) {
+	    case 0: return function () {
+	      return fn.call(that);
+	    };
+	    case 1: return function (a) {
+	      return fn.call(that, a);
+	    };
+	    case 2: return function (a, b) {
+	      return fn.call(that, a, b);
+	    };
+	    case 3: return function (a, b, c) {
+	      return fn.call(that, a, b, c);
+	    };
+	  }
+	  return function (/* ...args */) {
+	    return fn.apply(that, arguments);
+	  };
+	};
+
+	var push = [].push;
+
+	// `Array.prototype.{ forEach, map, filter, some, every, find, findIndex }` methods implementation
+	var createMethod$1 = function (TYPE) {
+	  var IS_MAP = TYPE == 1;
+	  var IS_FILTER = TYPE == 2;
+	  var IS_SOME = TYPE == 3;
+	  var IS_EVERY = TYPE == 4;
+	  var IS_FIND_INDEX = TYPE == 6;
+	  var NO_HOLES = TYPE == 5 || IS_FIND_INDEX;
+	  return function ($this, callbackfn, that, specificCreate) {
+	    var O = toObject($this);
+	    var self = indexedObject(O);
+	    var boundFunction = bindContext(callbackfn, that, 3);
+	    var length = toLength(self.length);
+	    var index = 0;
+	    var create = specificCreate || arraySpeciesCreate;
+	    var target = IS_MAP ? create($this, length) : IS_FILTER ? create($this, 0) : undefined;
+	    var value, result;
+	    for (;length > index; index++) if (NO_HOLES || index in self) {
+	      value = self[index];
+	      result = boundFunction(value, index, O);
+	      if (TYPE) {
+	        if (IS_MAP) target[index] = result; // map
+	        else if (result) switch (TYPE) {
+	          case 3: return true;              // some
+	          case 5: return value;             // find
+	          case 6: return index;             // findIndex
+	          case 2: push.call(target, value); // filter
+	        } else if (IS_EVERY) return false;  // every
+	      }
+	    }
+	    return IS_FIND_INDEX ? -1 : IS_SOME || IS_EVERY ? IS_EVERY : target;
+	  };
+	};
+
+	var arrayIteration = {
+	  // `Array.prototype.forEach` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.foreach
+	  forEach: createMethod$1(0),
+	  // `Array.prototype.map` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.map
+	  map: createMethod$1(1),
+	  // `Array.prototype.filter` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.filter
+	  filter: createMethod$1(2),
+	  // `Array.prototype.some` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.some
+	  some: createMethod$1(3),
+	  // `Array.prototype.every` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.every
+	  every: createMethod$1(4),
+	  // `Array.prototype.find` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.find
+	  find: createMethod$1(5),
+	  // `Array.prototype.findIndex` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.findIndex
+	  findIndex: createMethod$1(6)
+	};
+
+	var $filter = arrayIteration.filter;
+
+
+
+	var HAS_SPECIES_SUPPORT = arrayMethodHasSpeciesSupport('filter');
+	// Edge 14- issue
+	var USES_TO_LENGTH = HAS_SPECIES_SUPPORT && !fails(function () {
+	  [].filter.call({ length: -1, 0: 1 }, function (it) { throw it; });
+	});
+
+	// `Array.prototype.filter` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.filter
+	// with adding support of @@species
+	_export({ target: 'Array', proto: true, forced: !HAS_SPECIES_SUPPORT || !USES_TO_LENGTH }, {
+	  filter: function filter(callbackfn /* , thisArg */) {
+	    return $filter(this, callbackfn, arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	// `Object.keys` method
+	// https://tc39.github.io/ecma262/#sec-object.keys
+	var objectKeys = Object.keys || function keys(O) {
+	  return objectKeysInternal(O, enumBugKeys);
+	};
+
+	// `Object.defineProperties` method
+	// https://tc39.github.io/ecma262/#sec-object.defineproperties
+	var objectDefineProperties = descriptors ? Object.defineProperties : function defineProperties(O, Properties) {
+	  anObject(O);
+	  var keys = objectKeys(Properties);
+	  var length = keys.length;
+	  var index = 0;
+	  var key;
+	  while (length > index) objectDefineProperty.f(O, key = keys[index++], Properties[key]);
+	  return O;
+	};
+
+	var html = getBuiltIn('document', 'documentElement');
+
+	var GT = '>';
+	var LT = '<';
+	var PROTOTYPE = 'prototype';
+	var SCRIPT = 'script';
+	var IE_PROTO = sharedKey('IE_PROTO');
+
+	var EmptyConstructor = function () { /* empty */ };
+
+	var scriptTag = function (content) {
+	  return LT + SCRIPT + GT + content + LT + '/' + SCRIPT + GT;
+	};
+
+	// Create object with fake `null` prototype: use ActiveX Object with cleared prototype
+	var NullProtoObjectViaActiveX = function (activeXDocument) {
+	  activeXDocument.write(scriptTag(''));
+	  activeXDocument.close();
+	  var temp = activeXDocument.parentWindow.Object;
+	  activeXDocument = null; // avoid memory leak
+	  return temp;
+	};
+
+	// Create object with fake `null` prototype: use iframe Object with cleared prototype
+	var NullProtoObjectViaIFrame = function () {
+	  // Thrash, waste and sodomy: IE GC bug
+	  var iframe = documentCreateElement('iframe');
+	  var JS = 'java' + SCRIPT + ':';
+	  var iframeDocument;
+	  iframe.style.display = 'none';
+	  html.appendChild(iframe);
+	  // https://github.com/zloirock/core-js/issues/475
+	  iframe.src = String(JS);
+	  iframeDocument = iframe.contentWindow.document;
+	  iframeDocument.open();
+	  iframeDocument.write(scriptTag('document.F=Object'));
+	  iframeDocument.close();
+	  return iframeDocument.F;
+	};
+
+	// Check for document.domain and active x support
+	// No need to use active x approach when document.domain is not set
+	// see https://github.com/es-shims/es5-shim/issues/150
+	// variation of https://github.com/kitcambridge/es5-shim/commit/4f738ac066346
+	// avoid IE GC bug
+	var activeXDocument;
+	var NullProtoObject = function () {
+	  try {
+	    /* global ActiveXObject */
+	    activeXDocument = document.domain && new ActiveXObject('htmlfile');
+	  } catch (error) { /* ignore */ }
+	  NullProtoObject = activeXDocument ? NullProtoObjectViaActiveX(activeXDocument) : NullProtoObjectViaIFrame();
+	  var length = enumBugKeys.length;
+	  while (length--) delete NullProtoObject[PROTOTYPE][enumBugKeys[length]];
+	  return NullProtoObject();
+	};
+
+	hiddenKeys[IE_PROTO] = true;
+
+	// `Object.create` method
+	// https://tc39.github.io/ecma262/#sec-object.create
+	var objectCreate = Object.create || function create(O, Properties) {
+	  var result;
+	  if (O !== null) {
+	    EmptyConstructor[PROTOTYPE] = anObject(O);
+	    result = new EmptyConstructor();
+	    EmptyConstructor[PROTOTYPE] = null;
+	    // add "__proto__" for Object.getPrototypeOf polyfill
+	    result[IE_PROTO] = O;
+	  } else result = NullProtoObject();
+	  return Properties === undefined ? result : objectDefineProperties(result, Properties);
+	};
+
+	var UNSCOPABLES = wellKnownSymbol('unscopables');
+	var ArrayPrototype = Array.prototype;
+
+	// Array.prototype[@@unscopables]
+	// https://tc39.github.io/ecma262/#sec-array.prototype-@@unscopables
+	if (ArrayPrototype[UNSCOPABLES] == undefined) {
+	  objectDefineProperty.f(ArrayPrototype, UNSCOPABLES, {
+	    configurable: true,
+	    value: objectCreate(null)
+	  });
+	}
+
+	// add a key to Array.prototype[@@unscopables]
+	var addToUnscopables = function (key) {
+	  ArrayPrototype[UNSCOPABLES][key] = true;
+	};
+
+	var $find = arrayIteration.find;
+
+
+	var FIND = 'find';
+	var SKIPS_HOLES = true;
+
+	// Shouldn't skip holes
+	if (FIND in []) Array(1)[FIND](function () { SKIPS_HOLES = false; });
+
+	// `Array.prototype.find` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.find
+	_export({ target: 'Array', proto: true, forced: SKIPS_HOLES }, {
+	  find: function find(callbackfn /* , that = undefined */) {
+	    return $find(this, callbackfn, arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	// https://tc39.github.io/ecma262/#sec-array.prototype-@@unscopables
+	addToUnscopables(FIND);
+
+	var $includes = arrayIncludes.includes;
+
+
+	// `Array.prototype.includes` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.includes
+	_export({ target: 'Array', proto: true }, {
+	  includes: function includes(el /* , fromIndex = 0 */) {
+	    return $includes(this, el, arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	// https://tc39.github.io/ecma262/#sec-array.prototype-@@unscopables
+	addToUnscopables('includes');
+
+	var sloppyArrayMethod = function (METHOD_NAME, argument) {
+	  var method = [][METHOD_NAME];
+	  return !method || !fails(function () {
+	    // eslint-disable-next-line no-useless-call,no-throw-literal
+	    method.call(null, argument || function () { throw 1; }, 1);
+	  });
+	};
+
+	var $indexOf = arrayIncludes.indexOf;
+
+
+	var nativeIndexOf = [].indexOf;
+
+	var NEGATIVE_ZERO = !!nativeIndexOf && 1 / [1].indexOf(1, -0) < 0;
+	var SLOPPY_METHOD = sloppyArrayMethod('indexOf');
+
+	// `Array.prototype.indexOf` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.indexof
+	_export({ target: 'Array', proto: true, forced: NEGATIVE_ZERO || SLOPPY_METHOD }, {
+	  indexOf: function indexOf(searchElement /* , fromIndex = 0 */) {
+	    return NEGATIVE_ZERO
+	      // convert -0 to +0
+	      ? nativeIndexOf.apply(this, arguments) || 0
+	      : $indexOf(this, searchElement, arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	var nativeAssign = Object.assign;
+	var defineProperty = Object.defineProperty;
+
+	// `Object.assign` method
+	// https://tc39.github.io/ecma262/#sec-object.assign
+	var objectAssign = !nativeAssign || fails(function () {
+	  // should have correct order of operations (Edge bug)
+	  if (descriptors && nativeAssign({ b: 1 }, nativeAssign(defineProperty({}, 'a', {
+	    enumerable: true,
+	    get: function () {
+	      defineProperty(this, 'b', {
+	        value: 3,
+	        enumerable: false
+	      });
+	    }
+	  }), { b: 2 })).b !== 1) return true;
+	  // should work with symbols and should have deterministic property order (V8 bug)
+	  var A = {};
+	  var B = {};
+	  // eslint-disable-next-line no-undef
+	  var symbol = Symbol();
+	  var alphabet = 'abcdefghijklmnopqrst';
+	  A[symbol] = 7;
+	  alphabet.split('').forEach(function (chr) { B[chr] = chr; });
+	  return nativeAssign({}, A)[symbol] != 7 || objectKeys(nativeAssign({}, B)).join('') != alphabet;
+	}) ? function assign(target, source) { // eslint-disable-line no-unused-vars
+	  var T = toObject(target);
+	  var argumentsLength = arguments.length;
+	  var index = 1;
+	  var getOwnPropertySymbols = objectGetOwnPropertySymbols.f;
+	  var propertyIsEnumerable = objectPropertyIsEnumerable.f;
+	  while (argumentsLength > index) {
+	    var S = indexedObject(arguments[index++]);
+	    var keys = getOwnPropertySymbols ? objectKeys(S).concat(getOwnPropertySymbols(S)) : objectKeys(S);
+	    var length = keys.length;
+	    var j = 0;
+	    var key;
+	    while (length > j) {
+	      key = keys[j++];
+	      if (!descriptors || propertyIsEnumerable.call(S, key)) T[key] = S[key];
+	    }
+	  } return T;
+	} : nativeAssign;
+
+	// `Object.assign` method
+	// https://tc39.github.io/ecma262/#sec-object.assign
+	_export({ target: 'Object', stat: true, forced: Object.assign !== objectAssign }, {
+	  assign: objectAssign
+	});
+
+	var FAILS_ON_PRIMITIVES = fails(function () { objectKeys(1); });
+
+	// `Object.keys` method
+	// https://tc39.github.io/ecma262/#sec-object.keys
+	_export({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES }, {
+	  keys: function keys(it) {
+	    return objectKeys(toObject(it));
+	  }
+	});
+
+	var TO_STRING_TAG = wellKnownSymbol('toStringTag');
+	var test = {};
+
+	test[TO_STRING_TAG] = 'z';
+
+	var toStringTagSupport = String(test) === '[object z]';
+
+	var TO_STRING_TAG$1 = wellKnownSymbol('toStringTag');
+	// ES3 wrong here
+	var CORRECT_ARGUMENTS = classofRaw(function () { return arguments; }()) == 'Arguments';
+
+	// fallback for IE11 Script Access Denied error
+	var tryGet = function (it, key) {
+	  try {
+	    return it[key];
+	  } catch (error) { /* empty */ }
+	};
+
+	// getting tag from ES6+ `Object.prototype.toString`
+	var classof = toStringTagSupport ? classofRaw : function (it) {
+	  var O, tag, result;
+	  return it === undefined ? 'Undefined' : it === null ? 'Null'
+	    // @@toStringTag case
+	    : typeof (tag = tryGet(O = Object(it), TO_STRING_TAG$1)) == 'string' ? tag
+	    // builtinTag case
+	    : CORRECT_ARGUMENTS ? classofRaw(O)
+	    // ES3 arguments fallback
+	    : (result = classofRaw(O)) == 'Object' && typeof O.callee == 'function' ? 'Arguments' : result;
+	};
+
+	// `Object.prototype.toString` method implementation
+	// https://tc39.github.io/ecma262/#sec-object.prototype.tostring
+	var objectToString = toStringTagSupport ? {}.toString : function toString() {
+	  return '[object ' + classof(this) + ']';
+	};
+
+	// `Object.prototype.toString` method
+	// https://tc39.github.io/ecma262/#sec-object.prototype.tostring
+	if (!toStringTagSupport) {
+	  redefine(Object.prototype, 'toString', objectToString, { unsafe: true });
+	}
+
+	// a string of all valid unicode whitespaces
+	// eslint-disable-next-line max-len
+	var whitespaces = '\u0009\u000A\u000B\u000C\u000D\u0020\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF';
+
+	var whitespace = '[' + whitespaces + ']';
+	var ltrim = RegExp('^' + whitespace + whitespace + '*');
+	var rtrim = RegExp(whitespace + whitespace + '*$');
+
+	// `String.prototype.{ trim, trimStart, trimEnd, trimLeft, trimRight }` methods implementation
+	var createMethod$2 = function (TYPE) {
+	  return function ($this) {
+	    var string = String(requireObjectCoercible($this));
+	    if (TYPE & 1) string = string.replace(ltrim, '');
+	    if (TYPE & 2) string = string.replace(rtrim, '');
+	    return string;
+	  };
+	};
+
+	var stringTrim = {
+	  // `String.prototype.{ trimLeft, trimStart }` methods
+	  // https://tc39.github.io/ecma262/#sec-string.prototype.trimstart
+	  start: createMethod$2(1),
+	  // `String.prototype.{ trimRight, trimEnd }` methods
+	  // https://tc39.github.io/ecma262/#sec-string.prototype.trimend
+	  end: createMethod$2(2),
+	  // `String.prototype.trim` method
+	  // https://tc39.github.io/ecma262/#sec-string.prototype.trim
+	  trim: createMethod$2(3)
+	};
+
+	var trim = stringTrim.trim;
+
+
+	var nativeParseInt = global_1.parseInt;
+	var hex = /^[+-]?0[Xx]/;
+	var FORCED$1 = nativeParseInt(whitespaces + '08') !== 8 || nativeParseInt(whitespaces + '0x16') !== 22;
+
+	// `parseInt` method
+	// https://tc39.github.io/ecma262/#sec-parseint-string-radix
+	var _parseInt = FORCED$1 ? function parseInt(string, radix) {
+	  var S = trim(String(string));
+	  return nativeParseInt(S, (radix >>> 0) || (hex.test(S) ? 16 : 10));
+	} : nativeParseInt;
+
+	// `parseInt` method
+	// https://tc39.github.io/ecma262/#sec-parseint-string-radix
+	_export({ global: true, forced: parseInt != _parseInt }, {
+	  parseInt: _parseInt
+	});
+
+	// `RegExp.prototype.flags` getter implementation
+	// https://tc39.github.io/ecma262/#sec-get-regexp.prototype.flags
+	var regexpFlags = function () {
+	  var that = anObject(this);
+	  var result = '';
+	  if (that.global) result += 'g';
+	  if (that.ignoreCase) result += 'i';
+	  if (that.multiline) result += 'm';
+	  if (that.dotAll) result += 's';
+	  if (that.unicode) result += 'u';
+	  if (that.sticky) result += 'y';
+	  return result;
+	};
+
+	// babel-minify transpiles RegExp('a', 'y') -> /a/y and it causes SyntaxError,
+	// so we use an intermediate function.
+	function RE(s, f) {
+	  return RegExp(s, f);
+	}
+
+	var UNSUPPORTED_Y = fails(function () {
+	  // babel-minify transpiles RegExp('a', 'y') -> /a/y and it causes SyntaxError
+	  var re = RE('a', 'y');
+	  re.lastIndex = 2;
+	  return re.exec('abcd') != null;
+	});
+
+	var BROKEN_CARET = fails(function () {
+	  // https://bugzilla.mozilla.org/show_bug.cgi?id=773687
+	  var re = RE('^r', 'gy');
+	  re.lastIndex = 2;
+	  return re.exec('str') != null;
+	});
+
+	var regexpStickyHelpers = {
+		UNSUPPORTED_Y: UNSUPPORTED_Y,
+		BROKEN_CARET: BROKEN_CARET
+	};
+
+	var nativeExec = RegExp.prototype.exec;
+	// This always refers to the native implementation, because the
+	// String#replace polyfill uses ./fix-regexp-well-known-symbol-logic.js,
+	// which loads this file before patching the method.
+	var nativeReplace = String.prototype.replace;
+
+	var patchedExec = nativeExec;
+
+	var UPDATES_LAST_INDEX_WRONG = (function () {
+	  var re1 = /a/;
+	  var re2 = /b*/g;
+	  nativeExec.call(re1, 'a');
+	  nativeExec.call(re2, 'a');
+	  return re1.lastIndex !== 0 || re2.lastIndex !== 0;
+	})();
+
+	var UNSUPPORTED_Y$1 = regexpStickyHelpers.UNSUPPORTED_Y || regexpStickyHelpers.BROKEN_CARET;
+
+	// nonparticipating capturing group, copied from es5-shim's String#split patch.
+	var NPCG_INCLUDED = /()??/.exec('')[1] !== undefined;
+
+	var PATCH = UPDATES_LAST_INDEX_WRONG || NPCG_INCLUDED || UNSUPPORTED_Y$1;
+
+	if (PATCH) {
+	  patchedExec = function exec(str) {
+	    var re = this;
+	    var lastIndex, reCopy, match, i;
+	    var sticky = UNSUPPORTED_Y$1 && re.sticky;
+	    var flags = regexpFlags.call(re);
+	    var source = re.source;
+	    var charsAdded = 0;
+	    var strCopy = str;
+
+	    if (sticky) {
+	      flags = flags.replace('y', '');
+	      if (flags.indexOf('g') === -1) {
+	        flags += 'g';
+	      }
+
+	      strCopy = String(str).slice(re.lastIndex);
+	      // Support anchored sticky behavior.
+	      if (re.lastIndex > 0 && (!re.multiline || re.multiline && str[re.lastIndex - 1] !== '\n')) {
+	        source = '(?: ' + source + ')';
+	        strCopy = ' ' + strCopy;
+	        charsAdded++;
+	      }
+	      // ^(? + rx + ) is needed, in combination with some str slicing, to
+	      // simulate the 'y' flag.
+	      reCopy = new RegExp('^(?:' + source + ')', flags);
+	    }
+
+	    if (NPCG_INCLUDED) {
+	      reCopy = new RegExp('^' + source + '$(?!\\s)', flags);
+	    }
+	    if (UPDATES_LAST_INDEX_WRONG) lastIndex = re.lastIndex;
+
+	    match = nativeExec.call(sticky ? reCopy : re, strCopy);
+
+	    if (sticky) {
+	      if (match) {
+	        match.input = match.input.slice(charsAdded);
+	        match[0] = match[0].slice(charsAdded);
+	        match.index = re.lastIndex;
+	        re.lastIndex += match[0].length;
+	      } else re.lastIndex = 0;
+	    } else if (UPDATES_LAST_INDEX_WRONG && match) {
+	      re.lastIndex = re.global ? match.index + match[0].length : lastIndex;
+	    }
+	    if (NPCG_INCLUDED && match && match.length > 1) {
+	      // Fix browsers whose `exec` methods don't consistently return `undefined`
+	      // for NPCG, like IE8. NOTE: This doesn' work for /(.?)?/
+	      nativeReplace.call(match[0], reCopy, function () {
+	        for (i = 1; i < arguments.length - 2; i++) {
+	          if (arguments[i] === undefined) match[i] = undefined;
+	        }
+	      });
+	    }
+
+	    return match;
+	  };
+	}
+
+	var regexpExec = patchedExec;
+
+	_export({ target: 'RegExp', proto: true, forced: /./.exec !== regexpExec }, {
+	  exec: regexpExec
+	});
+
+	var TO_STRING = 'toString';
+	var RegExpPrototype = RegExp.prototype;
+	var nativeToString = RegExpPrototype[TO_STRING];
+
+	var NOT_GENERIC = fails(function () { return nativeToString.call({ source: 'a', flags: 'b' }) != '/a/b'; });
+	// FF44- RegExp#toString has a wrong name
+	var INCORRECT_NAME = nativeToString.name != TO_STRING;
+
+	// `RegExp.prototype.toString` method
+	// https://tc39.github.io/ecma262/#sec-regexp.prototype.tostring
+	if (NOT_GENERIC || INCORRECT_NAME) {
+	  redefine(RegExp.prototype, TO_STRING, function toString() {
+	    var R = anObject(this);
+	    var p = String(R.source);
+	    var rf = R.flags;
+	    var f = String(rf === undefined && R instanceof RegExp && !('flags' in RegExpPrototype) ? regexpFlags.call(R) : rf);
+	    return '/' + p + '/' + f;
+	  }, { unsafe: true });
+	}
+
+	var MATCH = wellKnownSymbol('match');
+
+	// `IsRegExp` abstract operation
+	// https://tc39.github.io/ecma262/#sec-isregexp
+	var isRegexp = function (it) {
+	  var isRegExp;
+	  return isObject(it) && ((isRegExp = it[MATCH]) !== undefined ? !!isRegExp : classofRaw(it) == 'RegExp');
+	};
+
+	var notARegexp = function (it) {
+	  if (isRegexp(it)) {
+	    throw TypeError("The method doesn't accept regular expressions");
+	  } return it;
+	};
+
+	var MATCH$1 = wellKnownSymbol('match');
+
+	var correctIsRegexpLogic = function (METHOD_NAME) {
+	  var regexp = /./;
+	  try {
+	    '/./'[METHOD_NAME](regexp);
+	  } catch (e) {
+	    try {
+	      regexp[MATCH$1] = false;
+	      return '/./'[METHOD_NAME](regexp);
+	    } catch (f) { /* empty */ }
+	  } return false;
+	};
+
+	// `String.prototype.includes` method
+	// https://tc39.github.io/ecma262/#sec-string.prototype.includes
+	_export({ target: 'String', proto: true, forced: !correctIsRegexpLogic('includes') }, {
+	  includes: function includes(searchString /* , position = 0 */) {
+	    return !!~String(requireObjectCoercible(this))
+	      .indexOf(notARegexp(searchString), arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	var non = '\u200B\u0085\u180E';
+
+	// check that a method works with the correct list
+	// of whitespaces and has a correct name
+	var forcedStringTrimMethod = function (METHOD_NAME) {
+	  return fails(function () {
+	    return !!whitespaces[METHOD_NAME]() || non[METHOD_NAME]() != non || whitespaces[METHOD_NAME].name !== METHOD_NAME;
+	  });
+	};
+
+	var $trim = stringTrim.trim;
+
+
+	// `String.prototype.trim` method
+	// https://tc39.github.io/ecma262/#sec-string.prototype.trim
+	_export({ target: 'String', proto: true, forced: forcedStringTrimMethod('trim') }, {
+	  trim: function trim() {
+	    return $trim(this);
+	  }
+	});
+
+	// iterable DOM collections
+	// flag - `iterable` interface - 'entries', 'keys', 'values', 'forEach' methods
+	var domIterables = {
+	  CSSRuleList: 0,
+	  CSSStyleDeclaration: 0,
+	  CSSValueList: 0,
+	  ClientRectList: 0,
+	  DOMRectList: 0,
+	  DOMStringList: 0,
+	  DOMTokenList: 1,
+	  DataTransferItemList: 0,
+	  FileList: 0,
+	  HTMLAllCollection: 0,
+	  HTMLCollection: 0,
+	  HTMLFormElement: 0,
+	  HTMLSelectElement: 0,
+	  MediaList: 0,
+	  MimeTypeArray: 0,
+	  NamedNodeMap: 0,
+	  NodeList: 1,
+	  PaintRequestList: 0,
+	  Plugin: 0,
+	  PluginArray: 0,
+	  SVGLengthList: 0,
+	  SVGNumberList: 0,
+	  SVGPathSegList: 0,
+	  SVGPointList: 0,
+	  SVGStringList: 0,
+	  SVGTransformList: 0,
+	  SourceBufferList: 0,
+	  StyleSheetList: 0,
+	  TextTrackCueList: 0,
+	  TextTrackList: 0,
+	  TouchList: 0
+	};
+
+	var $forEach = arrayIteration.forEach;
+
+
+	// `Array.prototype.forEach` method implementation
+	// https://tc39.github.io/ecma262/#sec-array.prototype.foreach
+	var arrayForEach = sloppyArrayMethod('forEach') ? function forEach(callbackfn /* , thisArg */) {
+	  return $forEach(this, callbackfn, arguments.length > 1 ? arguments[1] : undefined);
+	} : [].forEach;
+
+	for (var COLLECTION_NAME in domIterables) {
+	  var Collection = global_1[COLLECTION_NAME];
+	  var CollectionPrototype = Collection && Collection.prototype;
+	  // some Chrome versions have non-configurable methods on DOMTokenList
+	  if (CollectionPrototype && CollectionPrototype.forEach !== arrayForEach) try {
+	    createNonEnumerableProperty(CollectionPrototype, 'forEach', arrayForEach);
+	  } catch (error) {
+	    CollectionPrototype.forEach = arrayForEach;
+	  }
+	}
+
+	function _typeof(obj) {
+	  if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
+	    _typeof = function (obj) {
+	      return typeof obj;
+	    };
+	  } else {
+	    _typeof = function (obj) {
+	      return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
+	    };
+	  }
+
+	  return _typeof(obj);
+	}
+
+	function _classCallCheck(instance, Constructor) {
+	  if (!(instance instanceof Constructor)) {
+	    throw new TypeError("Cannot call a class as a function");
+	  }
+	}
+
+	function _defineProperties(target, props) {
+	  for (var i = 0; i < props.length; i++) {
+	    var descriptor = props[i];
+	    descriptor.enumerable = descriptor.enumerable || false;
+	    descriptor.configurable = true;
+	    if ("value" in descriptor) descriptor.writable = true;
+	    Object.defineProperty(target, descriptor.key, descriptor);
+	  }
+	}
+
+	function _createClass(Constructor, protoProps, staticProps) {
+	  if (protoProps) _defineProperties(Constructor.prototype, protoProps);
+	  if (staticProps) _defineProperties(Constructor, staticProps);
+	  return Constructor;
+	}
+
+	function _inherits(subClass, superClass) {
+	  if (typeof superClass !== "function" && superClass !== null) {
+	    throw new TypeError("Super expression must either be null or a function");
+	  }
+
+	  subClass.prototype = Object.create(superClass && superClass.prototype, {
+	    constructor: {
+	      value: subClass,
+	      writable: true,
+	      configurable: true
+	    }
+	  });
+	  if (superClass) _setPrototypeOf(subClass, superClass);
+	}
+
+	function _getPrototypeOf(o) {
+	  _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) {
+	    return o.__proto__ || Object.getPrototypeOf(o);
+	  };
+	  return _getPrototypeOf(o);
+	}
+
+	function _setPrototypeOf(o, p) {
+	  _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
+	    o.__proto__ = p;
+	    return o;
+	  };
+
+	  return _setPrototypeOf(o, p);
+	}
+
+	function _assertThisInitialized(self) {
+	  if (self === void 0) {
+	    throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
+	  }
+
+	  return self;
+	}
+
+	function _possibleConstructorReturn(self, call) {
+	  if (call && (typeof call === "object" || typeof call === "function")) {
+	    return call;
+	  }
+
+	  return _assertThisInitialized(self);
+	}
+
+	function _superPropBase(object, property) {
+	  while (!Object.prototype.hasOwnProperty.call(object, property)) {
+	    object = _getPrototypeOf(object);
+	    if (object === null) break;
+	  }
+
+	  return object;
+	}
+
+	function _get(target, property, receiver) {
+	  if (typeof Reflect !== "undefined" && Reflect.get) {
+	    _get = Reflect.get;
+	  } else {
+	    _get = function _get(target, property, receiver) {
+	      var base = _superPropBase(target, property);
+
+	      if (!base) return;
+	      var desc = Object.getOwnPropertyDescriptor(base, property);
+
+	      if (desc.get) {
+	        return desc.get.call(receiver);
+	      }
+
+	      return desc.value;
+	    };
+	  }
+
+	  return _get(target, property, receiver || target);
+	}
+
+	function _toConsumableArray(arr) {
+	  return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread();
+	}
+
+	function _arrayWithoutHoles(arr) {
+	  if (Array.isArray(arr)) {
+	    for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) arr2[i] = arr[i];
+
+	    return arr2;
+	  }
+	}
+
+	function _iterableToArray(iter) {
+	  if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter);
+	}
+
+	function _nonIterableSpread() {
+	  throw new TypeError("Invalid attempt to spread non-iterable instance");
+	}
+
+	var nativeJoin = [].join;
+
+	var ES3_STRINGS = indexedObject != Object;
+	var SLOPPY_METHOD$1 = sloppyArrayMethod('join', ',');
+
+	// `Array.prototype.join` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.join
+	_export({ target: 'Array', proto: true, forced: ES3_STRINGS || SLOPPY_METHOD$1 }, {
+	  join: function join(separator) {
+	    return nativeJoin.call(toIndexedObject(this), separator === undefined ? ',' : separator);
+	  }
+	});
+
+	var test$1 = [];
+	var nativeSort = test$1.sort;
+
+	// IE8-
+	var FAILS_ON_UNDEFINED = fails(function () {
+	  test$1.sort(undefined);
+	});
+	// V8 bug
+	var FAILS_ON_NULL = fails(function () {
+	  test$1.sort(null);
+	});
+	// Old WebKit
+	var SLOPPY_METHOD$2 = sloppyArrayMethod('sort');
+
+	var FORCED$2 = FAILS_ON_UNDEFINED || !FAILS_ON_NULL || SLOPPY_METHOD$2;
+
+	// `Array.prototype.sort` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.sort
+	_export({ target: 'Array', proto: true, forced: FORCED$2 }, {
+	  sort: function sort(comparefn) {
+	    return comparefn === undefined
+	      ? nativeSort.call(toObject(this))
+	      : nativeSort.call(toObject(this), aFunction$1(comparefn));
+	  }
+	});
+
+	var SPECIES$2 = wellKnownSymbol('species');
+
+	var REPLACE_SUPPORTS_NAMED_GROUPS = !fails(function () {
+	  // #replace needs built-in support for named groups.
+	  // #match works fine because it just return the exec results, even if it has
+	  // a "grops" property.
+	  var re = /./;
+	  re.exec = function () {
+	    var result = [];
+	    result.groups = { a: '7' };
+	    return result;
+	  };
+	  return ''.replace(re, '$<a>') !== '7';
+	});
+
+	// IE <= 11 replaces $0 with the whole match, as if it was $&
+	// https://stackoverflow.com/questions/6024666/getting-ie-to-replace-a-regex-with-the-literal-string-0
+	var REPLACE_KEEPS_$0 = (function () {
+	  return 'a'.replace(/./, '$0') === '$0';
+	})();
+
+	// Chrome 51 has a buggy "split" implementation when RegExp#exec !== nativeExec
+	// Weex JS has frozen built-in prototypes, so use try / catch wrapper
+	var SPLIT_WORKS_WITH_OVERWRITTEN_EXEC = !fails(function () {
+	  var re = /(?:)/;
+	  var originalExec = re.exec;
+	  re.exec = function () { return originalExec.apply(this, arguments); };
+	  var result = 'ab'.split(re);
+	  return result.length !== 2 || result[0] !== 'a' || result[1] !== 'b';
+	});
+
+	var fixRegexpWellKnownSymbolLogic = function (KEY, length, exec, sham) {
+	  var SYMBOL = wellKnownSymbol(KEY);
+
+	  var DELEGATES_TO_SYMBOL = !fails(function () {
+	    // String methods call symbol-named RegEp methods
+	    var O = {};
+	    O[SYMBOL] = function () { return 7; };
+	    return ''[KEY](O) != 7;
+	  });
+
+	  var DELEGATES_TO_EXEC = DELEGATES_TO_SYMBOL && !fails(function () {
+	    // Symbol-named RegExp methods call .exec
+	    var execCalled = false;
+	    var re = /a/;
+
+	    if (KEY === 'split') {
+	      // We can't use real regex here since it causes deoptimization
+	      // and serious performance degradation in V8
+	      // https://github.com/zloirock/core-js/issues/306
+	      re = {};
+	      // RegExp[@@split] doesn't call the regex's exec method, but first creates
+	      // a new one. We need to return the patched regex when creating the new one.
+	      re.constructor = {};
+	      re.constructor[SPECIES$2] = function () { return re; };
+	      re.flags = '';
+	      re[SYMBOL] = /./[SYMBOL];
+	    }
+
+	    re.exec = function () { execCalled = true; return null; };
+
+	    re[SYMBOL]('');
+	    return !execCalled;
+	  });
+
+	  if (
+	    !DELEGATES_TO_SYMBOL ||
+	    !DELEGATES_TO_EXEC ||
+	    (KEY === 'replace' && !(REPLACE_SUPPORTS_NAMED_GROUPS && REPLACE_KEEPS_$0)) ||
+	    (KEY === 'split' && !SPLIT_WORKS_WITH_OVERWRITTEN_EXEC)
+	  ) {
+	    var nativeRegExpMethod = /./[SYMBOL];
+	    var methods = exec(SYMBOL, ''[KEY], function (nativeMethod, regexp, str, arg2, forceStringMethod) {
+	      if (regexp.exec === regexpExec) {
+	        if (DELEGATES_TO_SYMBOL && !forceStringMethod) {
+	          // The native String method already delegates to @@method (this
+	          // polyfilled function), leasing to infinite recursion.
+	          // We avoid it by directly calling the native @@method method.
+	          return { done: true, value: nativeRegExpMethod.call(regexp, str, arg2) };
+	        }
+	        return { done: true, value: nativeMethod.call(str, regexp, arg2) };
+	      }
+	      return { done: false };
+	    }, { REPLACE_KEEPS_$0: REPLACE_KEEPS_$0 });
+	    var stringMethod = methods[0];
+	    var regexMethod = methods[1];
+
+	    redefine(String.prototype, KEY, stringMethod);
+	    redefine(RegExp.prototype, SYMBOL, length == 2
+	      // 21.2.5.8 RegExp.prototype[@@replace](string, replaceValue)
+	      // 21.2.5.11 RegExp.prototype[@@split](string, limit)
+	      ? function (string, arg) { return regexMethod.call(string, this, arg); }
+	      // 21.2.5.6 RegExp.prototype[@@match](string)
+	      // 21.2.5.9 RegExp.prototype[@@search](string)
+	      : function (string) { return regexMethod.call(string, this); }
+	    );
+	  }
+
+	  if (sham) createNonEnumerableProperty(RegExp.prototype[SYMBOL], 'sham', true);
+	};
+
+	// `String.prototype.{ codePointAt, at }` methods implementation
+	var createMethod$3 = function (CONVERT_TO_STRING) {
+	  return function ($this, pos) {
+	    var S = String(requireObjectCoercible($this));
+	    var position = toInteger(pos);
+	    var size = S.length;
+	    var first, second;
+	    if (position < 0 || position >= size) return CONVERT_TO_STRING ? '' : undefined;
+	    first = S.charCodeAt(position);
+	    return first < 0xD800 || first > 0xDBFF || position + 1 === size
+	      || (second = S.charCodeAt(position + 1)) < 0xDC00 || second > 0xDFFF
+	        ? CONVERT_TO_STRING ? S.charAt(position) : first
+	        : CONVERT_TO_STRING ? S.slice(position, position + 2) : (first - 0xD800 << 10) + (second - 0xDC00) + 0x10000;
+	  };
+	};
+
+	var stringMultibyte = {
+	  // `String.prototype.codePointAt` method
+	  // https://tc39.github.io/ecma262/#sec-string.prototype.codepointat
+	  codeAt: createMethod$3(false),
+	  // `String.prototype.at` method
+	  // https://github.com/mathiasbynens/String.prototype.at
+	  charAt: createMethod$3(true)
+	};
+
+	var charAt = stringMultibyte.charAt;
+
+	// `AdvanceStringIndex` abstract operation
+	// https://tc39.github.io/ecma262/#sec-advancestringindex
+	var advanceStringIndex = function (S, index, unicode) {
+	  return index + (unicode ? charAt(S, index).length : 1);
+	};
+
+	// `RegExpExec` abstract operation
+	// https://tc39.github.io/ecma262/#sec-regexpexec
+	var regexpExecAbstract = function (R, S) {
+	  var exec = R.exec;
+	  if (typeof exec === 'function') {
+	    var result = exec.call(R, S);
+	    if (typeof result !== 'object') {
+	      throw TypeError('RegExp exec method returned something other than an Object or null');
+	    }
+	    return result;
+	  }
+
+	  if (classofRaw(R) !== 'RegExp') {
+	    throw TypeError('RegExp#exec called on incompatible receiver');
+	  }
+
+	  return regexpExec.call(R, S);
+	};
+
+	// @@match logic
+	fixRegexpWellKnownSymbolLogic('match', 1, function (MATCH, nativeMatch, maybeCallNative) {
+	  return [
+	    // `String.prototype.match` method
+	    // https://tc39.github.io/ecma262/#sec-string.prototype.match
+	    function match(regexp) {
+	      var O = requireObjectCoercible(this);
+	      var matcher = regexp == undefined ? undefined : regexp[MATCH];
+	      return matcher !== undefined ? matcher.call(regexp, O) : new RegExp(regexp)[MATCH](String(O));
+	    },
+	    // `RegExp.prototype[@@match]` method
+	    // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@match
+	    function (regexp) {
+	      var res = maybeCallNative(nativeMatch, regexp, this);
+	      if (res.done) return res.value;
+
+	      var rx = anObject(regexp);
+	      var S = String(this);
+
+	      if (!rx.global) return regexpExecAbstract(rx, S);
+
+	      var fullUnicode = rx.unicode;
+	      rx.lastIndex = 0;
+	      var A = [];
+	      var n = 0;
+	      var result;
+	      while ((result = regexpExecAbstract(rx, S)) !== null) {
+	        var matchStr = String(result[0]);
+	        A[n] = matchStr;
+	        if (matchStr === '') rx.lastIndex = advanceStringIndex(S, toLength(rx.lastIndex), fullUnicode);
+	        n++;
+	      }
+	      return n === 0 ? null : A;
+	    }
+	  ];
+	});
+
+	var max$1 = Math.max;
+	var min$2 = Math.min;
+	var floor$1 = Math.floor;
+	var SUBSTITUTION_SYMBOLS = /\$([$&'`]|\d\d?|<[^>]*>)/g;
+	var SUBSTITUTION_SYMBOLS_NO_NAMED = /\$([$&'`]|\d\d?)/g;
+
+	var maybeToString = function (it) {
+	  return it === undefined ? it : String(it);
+	};
+
+	// @@replace logic
+	fixRegexpWellKnownSymbolLogic('replace', 2, function (REPLACE, nativeReplace, maybeCallNative, reason) {
+	  return [
+	    // `String.prototype.replace` method
+	    // https://tc39.github.io/ecma262/#sec-string.prototype.replace
+	    function replace(searchValue, replaceValue) {
+	      var O = requireObjectCoercible(this);
+	      var replacer = searchValue == undefined ? undefined : searchValue[REPLACE];
+	      return replacer !== undefined
+	        ? replacer.call(searchValue, O, replaceValue)
+	        : nativeReplace.call(String(O), searchValue, replaceValue);
+	    },
+	    // `RegExp.prototype[@@replace]` method
+	    // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@replace
+	    function (regexp, replaceValue) {
+	      if (reason.REPLACE_KEEPS_$0 || (typeof replaceValue === 'string' && replaceValue.indexOf('$0') === -1)) {
+	        var res = maybeCallNative(nativeReplace, regexp, this, replaceValue);
+	        if (res.done) return res.value;
+	      }
+
+	      var rx = anObject(regexp);
+	      var S = String(this);
+
+	      var functionalReplace = typeof replaceValue === 'function';
+	      if (!functionalReplace) replaceValue = String(replaceValue);
+
+	      var global = rx.global;
+	      if (global) {
+	        var fullUnicode = rx.unicode;
+	        rx.lastIndex = 0;
+	      }
+	      var results = [];
+	      while (true) {
+	        var result = regexpExecAbstract(rx, S);
+	        if (result === null) break;
+
+	        results.push(result);
+	        if (!global) break;
+
+	        var matchStr = String(result[0]);
+	        if (matchStr === '') rx.lastIndex = advanceStringIndex(S, toLength(rx.lastIndex), fullUnicode);
+	      }
+
+	      var accumulatedResult = '';
+	      var nextSourcePosition = 0;
+	      for (var i = 0; i < results.length; i++) {
+	        result = results[i];
+
+	        var matched = String(result[0]);
+	        var position = max$1(min$2(toInteger(result.index), S.length), 0);
+	        var captures = [];
+	        // NOTE: This is equivalent to
+	        //   captures = result.slice(1).map(maybeToString)
+	        // but for some reason `nativeSlice.call(result, 1, result.length)` (called in
+	        // the slice polyfill when slicing native arrays) "doesn't work" in safari 9 and
+	        // causes a crash (https://pastebin.com/N21QzeQA) when trying to debug it.
+	        for (var j = 1; j < result.length; j++) captures.push(maybeToString(result[j]));
+	        var namedCaptures = result.groups;
+	        if (functionalReplace) {
+	          var replacerArgs = [matched].concat(captures, position, S);
+	          if (namedCaptures !== undefined) replacerArgs.push(namedCaptures);
+	          var replacement = String(replaceValue.apply(undefined, replacerArgs));
+	        } else {
+	          replacement = getSubstitution(matched, S, position, captures, namedCaptures, replaceValue);
+	        }
+	        if (position >= nextSourcePosition) {
+	          accumulatedResult += S.slice(nextSourcePosition, position) + replacement;
+	          nextSourcePosition = position + matched.length;
+	        }
+	      }
+	      return accumulatedResult + S.slice(nextSourcePosition);
+	    }
+	  ];
+
+	  // https://tc39.github.io/ecma262/#sec-getsubstitution
+	  function getSubstitution(matched, str, position, captures, namedCaptures, replacement) {
+	    var tailPos = position + matched.length;
+	    var m = captures.length;
+	    var symbols = SUBSTITUTION_SYMBOLS_NO_NAMED;
+	    if (namedCaptures !== undefined) {
+	      namedCaptures = toObject(namedCaptures);
+	      symbols = SUBSTITUTION_SYMBOLS;
+	    }
+	    return nativeReplace.call(replacement, symbols, function (match, ch) {
+	      var capture;
+	      switch (ch.charAt(0)) {
+	        case '$': return '$';
+	        case '&': return matched;
+	        case '`': return str.slice(0, position);
+	        case "'": return str.slice(tailPos);
+	        case '<':
+	          capture = namedCaptures[ch.slice(1, -1)];
+	          break;
+	        default: // \d\d?
+	          var n = +ch;
+	          if (n === 0) return match;
+	          if (n > m) {
+	            var f = floor$1(n / 10);
+	            if (f === 0) return match;
+	            if (f <= m) return captures[f - 1] === undefined ? ch.charAt(1) : captures[f - 1] + ch.charAt(1);
+	            return match;
+	          }
+	          capture = captures[n - 1];
+	      }
+	      return capture === undefined ? '' : capture;
+	    });
+	  }
+	});
+
+	var SPECIES$3 = wellKnownSymbol('species');
+
+	// `SpeciesConstructor` abstract operation
+	// https://tc39.github.io/ecma262/#sec-speciesconstructor
+	var speciesConstructor = function (O, defaultConstructor) {
+	  var C = anObject(O).constructor;
+	  var S;
+	  return C === undefined || (S = anObject(C)[SPECIES$3]) == undefined ? defaultConstructor : aFunction$1(S);
+	};
+
+	var arrayPush = [].push;
+	var min$3 = Math.min;
+	var MAX_UINT32 = 0xFFFFFFFF;
+
+	// babel-minify transpiles RegExp('x', 'y') -> /x/y and it causes SyntaxError
+	var SUPPORTS_Y = !fails(function () { return !RegExp(MAX_UINT32, 'y'); });
+
+	// @@split logic
+	fixRegexpWellKnownSymbolLogic('split', 2, function (SPLIT, nativeSplit, maybeCallNative) {
+	  var internalSplit;
+	  if (
+	    'abbc'.split(/(b)*/)[1] == 'c' ||
+	    'test'.split(/(?:)/, -1).length != 4 ||
+	    'ab'.split(/(?:ab)*/).length != 2 ||
+	    '.'.split(/(.?)(.?)/).length != 4 ||
+	    '.'.split(/()()/).length > 1 ||
+	    ''.split(/.?/).length
+	  ) {
+	    // based on es5-shim implementation, need to rework it
+	    internalSplit = function (separator, limit) {
+	      var string = String(requireObjectCoercible(this));
+	      var lim = limit === undefined ? MAX_UINT32 : limit >>> 0;
+	      if (lim === 0) return [];
+	      if (separator === undefined) return [string];
+	      // If `separator` is not a regex, use native split
+	      if (!isRegexp(separator)) {
+	        return nativeSplit.call(string, separator, lim);
+	      }
+	      var output = [];
+	      var flags = (separator.ignoreCase ? 'i' : '') +
+	                  (separator.multiline ? 'm' : '') +
+	                  (separator.unicode ? 'u' : '') +
+	                  (separator.sticky ? 'y' : '');
+	      var lastLastIndex = 0;
+	      // Make `global` and avoid `lastIndex` issues by working with a copy
+	      var separatorCopy = new RegExp(separator.source, flags + 'g');
+	      var match, lastIndex, lastLength;
+	      while (match = regexpExec.call(separatorCopy, string)) {
+	        lastIndex = separatorCopy.lastIndex;
+	        if (lastIndex > lastLastIndex) {
+	          output.push(string.slice(lastLastIndex, match.index));
+	          if (match.length > 1 && match.index < string.length) arrayPush.apply(output, match.slice(1));
+	          lastLength = match[0].length;
+	          lastLastIndex = lastIndex;
+	          if (output.length >= lim) break;
+	        }
+	        if (separatorCopy.lastIndex === match.index) separatorCopy.lastIndex++; // Avoid an infinite loop
+	      }
+	      if (lastLastIndex === string.length) {
+	        if (lastLength || !separatorCopy.test('')) output.push('');
+	      } else output.push(string.slice(lastLastIndex));
+	      return output.length > lim ? output.slice(0, lim) : output;
+	    };
+	  // Chakra, V8
+	  } else if ('0'.split(undefined, 0).length) {
+	    internalSplit = function (separator, limit) {
+	      return separator === undefined && limit === 0 ? [] : nativeSplit.call(this, separator, limit);
+	    };
+	  } else internalSplit = nativeSplit;
+
+	  return [
+	    // `String.prototype.split` method
+	    // https://tc39.github.io/ecma262/#sec-string.prototype.split
+	    function split(separator, limit) {
+	      var O = requireObjectCoercible(this);
+	      var splitter = separator == undefined ? undefined : separator[SPLIT];
+	      return splitter !== undefined
+	        ? splitter.call(separator, O, limit)
+	        : internalSplit.call(String(O), separator, limit);
+	    },
+	    // `RegExp.prototype[@@split]` method
+	    // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@split
+	    //
+	    // NOTE: This cannot be properly polyfilled in engines that don't support
+	    // the 'y' flag.
+	    function (regexp, limit) {
+	      var res = maybeCallNative(internalSplit, regexp, this, limit, internalSplit !== nativeSplit);
+	      if (res.done) return res.value;
+
+	      var rx = anObject(regexp);
+	      var S = String(this);
+	      var C = speciesConstructor(rx, RegExp);
+
+	      var unicodeMatching = rx.unicode;
+	      var flags = (rx.ignoreCase ? 'i' : '') +
+	                  (rx.multiline ? 'm' : '') +
+	                  (rx.unicode ? 'u' : '') +
+	                  (SUPPORTS_Y ? 'y' : 'g');
+
+	      // ^(? + rx + ) is needed, in combination with some S slicing, to
+	      // simulate the 'y' flag.
+	      var splitter = new C(SUPPORTS_Y ? rx : '^(?:' + rx.source + ')', flags);
+	      var lim = limit === undefined ? MAX_UINT32 : limit >>> 0;
+	      if (lim === 0) return [];
+	      if (S.length === 0) return regexpExecAbstract(splitter, S) === null ? [S] : [];
+	      var p = 0;
+	      var q = 0;
+	      var A = [];
+	      while (q < S.length) {
+	        splitter.lastIndex = SUPPORTS_Y ? q : 0;
+	        var z = regexpExecAbstract(splitter, SUPPORTS_Y ? S : S.slice(q));
+	        var e;
+	        if (
+	          z === null ||
+	          (e = min$3(toLength(splitter.lastIndex + (SUPPORTS_Y ? 0 : q)), S.length)) === p
+	        ) {
+	          q = advanceStringIndex(S, q, unicodeMatching);
+	        } else {
+	          A.push(S.slice(p, q));
+	          if (A.length === lim) return A;
+	          for (var i = 1; i <= z.length - 1; i++) {
+	            A.push(z[i]);
+	            if (A.length === lim) return A;
+	          }
+	          q = p = e;
+	        }
+	      }
+	      A.push(S.slice(p));
+	      return A;
+	    }
+	  ];
+	}, !SUPPORTS_Y);
+
+	var Utils = $.fn.bootstrapTable.utils;
+	var searchControls = 'select, input:not([type="checkbox"]):not([type="radio"])';
+	function getOptionsFromSelectControl(selectControl) {
+	  return selectControl.get(selectControl.length - 1).options;
+	}
+	function getControlContainer(that) {
+	  if (that.options.filterControlContainer) {
+	    return $("".concat(that.options.filterControlContainer));
+	  }
+
+	  return that.$header;
+	}
+	function getSearchControls(that) {
+	  return getControlContainer(that).find(searchControls);
+	}
+	function hideUnusedSelectOptions(selectControl, uniqueValues) {
+	  var options = getOptionsFromSelectControl(selectControl);
+
+	  for (var i = 0; i < options.length; i++) {
+	    if (options[i].value !== '') {
+	      if (!uniqueValues.hasOwnProperty(options[i].value)) {
+	        selectControl.find(Utils.sprintf('option[value=\'%s\']', options[i].value)).hide();
+	      } else {
+	        selectControl.find(Utils.sprintf('option[value=\'%s\']', options[i].value)).show();
+	      }
+	    }
+	  }
+	}
+	function existOptionInSelectControl(selectControl, value) {
+	  var options = getOptionsFromSelectControl(selectControl);
+
+	  for (var i = 0; i < options.length; i++) {
+	    if (options[i].value === value.toString()) {
+	      // The value is not valid to add
+	      return true;
+	    }
+	  } // If we get here, the value is valid to add
+
+
+	  return false;
+	}
+	function addOptionToSelectControl(selectControl, _value, text, selected) {
+	  var value = _value === undefined || _value === null ? '' : _value.toString().trim();
+	  var $selectControl = $(selectControl.get(selectControl.length - 1));
+
+	  if (!existOptionInSelectControl(selectControl, value)) {
+	    var option = $("<option value=\"".concat(value, "\">").concat(text, "</option>"));
+
+	    if (value === selected) {
+	      option.attr('selected', true);
+	    }
+
+	    $selectControl.append(option);
+	  }
+	}
+	function sortSelectControl(selectControl, orderBy) {
+	  var $selectControl = $(selectControl.get(selectControl.length - 1));
+	  var $opts = $selectControl.find('option:gt(0)');
+
+	  if (orderBy !== 'server') {
+	    $opts.sort(function (a, b) {
+	      return Utils.sort(a.textContent, b.textContent, orderBy === 'desc' ? -1 : 1);
+	    });
+	  }
+
+	  $selectControl.find('option:gt(0)').remove();
+	  $selectControl.append($opts);
+	}
+	function fixHeaderCSS(_ref) {
+	  var $tableHeader = _ref.$tableHeader;
+	  $tableHeader.css('height', '89px');
+	}
+	function getElementClass($element) {
+	  return $element.attr('class').replace('form-control', '').replace('focus-temp', '').replace('search-input', '').trim();
+	}
+	function getCursorPosition(el) {
+	  if (Utils.isIEBrowser()) {
+	    if ($(el).is('input[type=text]')) {
+	      var pos = 0;
+
+	      if ('selectionStart' in el) {
+	        pos = el.selectionStart;
+	      } else if ('selection' in document) {
+	        el.focus();
+	        var Sel = document.selection.createRange();
+	        var SelLength = document.selection.createRange().text.length;
+	        Sel.moveStart('character', -el.value.length);
+	        pos = Sel.text.length - SelLength;
+	      }
+
+	      return pos;
+	    }
+
+	    return -1;
+	  }
+
+	  return -1;
+	}
+	function setCursorPosition(el) {
+	  $(el).val(el.value);
+	}
+	function copyValues(that) {
+	  var searchControls = getSearchControls(that);
+	  that.options.valuesFilterControl = [];
+	  searchControls.each(function () {
+	    var $field = $(this);
+
+	    if (that.options.height) {
+	      var fieldClass = getElementClass($field);
+	      $field = $(".fixed-table-header .".concat(fieldClass));
+	    }
+
+	    that.options.valuesFilterControl.push({
+	      field: $field.closest('[data-field]').data('field'),
+	      value: $field.val(),
+	      position: getCursorPosition($field.get(0)),
+	      hasFocus: $field.is(':focus')
+	    });
+	  });
+	}
+	function setValues(that) {
+	  var field = null;
+	  var result = [];
+	  var searchControls = getSearchControls(that);
+
+	  if (that.options.valuesFilterControl.length > 0) {
+	    //  Callback to apply after settings fields values
+	    var fieldToFocusCallback = null;
+	    searchControls.each(function (index, ele) {
+	      var $this = $(this);
+	      field = $this.closest('[data-field]').data('field');
+	      result = that.options.valuesFilterControl.filter(function (valueObj) {
+	        return valueObj.field === field;
+	      });
+
+	      if (result.length > 0) {
+	        if ($this.is('[type=radio]')) {
+	          return;
+	        }
+
+	        $this.val(result[0].value);
+
+	        if (result[0].hasFocus && result[0].value !== '') {
+	          // set callback if the field had the focus.
+	          fieldToFocusCallback = function (fieldToFocus, carretPosition) {
+	            // Closure here to capture the field and cursor position
+	            var closedCallback = function closedCallback() {
+	              fieldToFocus.focus();
+	              setCursorPosition(fieldToFocus);
+	            };
+
+	            return closedCallback;
+	          }($this.get(0), result[0].position);
+	        }
+	      }
+	    }); // Callback call.
+
+	    if (fieldToFocusCallback !== null) {
+	      fieldToFocusCallback();
+	    }
+	  }
+	}
+	function collectBootstrapCookies() {
+	  var cookies = [];
+	  var foundCookies = document.cookie.match(/(?:bs.table.)(\w*)/g);
+	  var foundLocalStorage = localStorage;
+
+	  if (foundCookies) {
+	    $.each(foundCookies, function (i, _cookie) {
+	      var cookie = _cookie;
+
+	      if (/./.test(cookie)) {
+	        cookie = cookie.split('.').pop();
+	      }
+
+	      if ($.inArray(cookie, cookies) === -1) {
+	        cookies.push(cookie);
+	      }
+	    });
+	  }
+
+	  if (foundLocalStorage) {
+	    for (var i = 0; i < foundLocalStorage.length; i++) {
+	      var cookie = foundLocalStorage.key(i);
+
+	      if (/./.test(cookie)) {
+	        cookie = cookie.split('.').pop();
+	      }
+
+	      if (!cookies.includes(cookie)) {
+	        cookies.push(cookie);
+	      }
+	    }
+	  }
+
+	  return cookies;
+	}
+	function escapeID(id) {
+	  // eslint-disable-next-line no-useless-escape
+	  return String(id).replace(/([:.\[\],])/g, '\\$1');
+	}
+	function isColumnSearchableViaSelect(_ref2) {
+	  var filterControl = _ref2.filterControl,
+	      searchable = _ref2.searchable;
+	  return filterControl && filterControl.toLowerCase() === 'select' && searchable;
+	}
+	function isFilterDataNotGiven(_ref3) {
+	  var filterData = _ref3.filterData;
+	  return filterData === undefined || filterData.toLowerCase() === 'column';
+	}
+	function hasSelectControlElement(selectControl) {
+	  return selectControl && selectControl.length > 0;
+	}
+	function initFilterSelectControls(that) {
+	  var data = that.data;
+	  var z = that.options.pagination ? that.options.sidePagination === 'server' ? that.pageTo : that.options.totalRows : that.pageTo;
+	  $.each(that.header.fields, function (j, field) {
+	    var column = that.columns[that.fieldsColumnsIndex[field]];
+	    var selectControl = getControlContainer(that).find("select.bootstrap-table-filter-control-".concat(escapeID(column.field)));
+
+	    if (isColumnSearchableViaSelect(column) && isFilterDataNotGiven(column) && hasSelectControlElement(selectControl)) {
+	      if (selectControl.get(selectControl.length - 1).options.length === 0) {
+	        // Added the default option
+	        addOptionToSelectControl(selectControl, '', column.filterControlPlaceholder, column.filterDefault);
+	      }
+
+	      var uniqueValues = {};
+
+	      for (var i = 0; i < z; i++) {
+	        // Added a new value
+	        var fieldValue = data[i][field];
+	        var formatter = that.options.editable && column.editable ? column._formatter : that.header.formatters[j];
+	        var formattedValue = Utils.calculateObjectValue(that.header, formatter, [fieldValue, data[i], i], fieldValue);
+
+	        if (column.filterDataCollector) {
+	          formattedValue = Utils.calculateObjectValue(that.header, column.filterDataCollector, [fieldValue, data[i], formattedValue], formattedValue);
+	        }
+
+	        if (column.searchFormatter) {
+	          fieldValue = formattedValue;
+	        }
+
+	        uniqueValues[formattedValue] = fieldValue;
+
+	        if (_typeof(formattedValue) === 'object' && formattedValue !== null) {
+	          formattedValue.forEach(function (value) {
+	            addOptionToSelectControl(selectControl, value, value, column.filterDefault);
+	          });
+	          continue;
+	        }
+
+	        for (var key in uniqueValues) {
+	          addOptionToSelectControl(selectControl, uniqueValues[key], key, column.filterDefault);
+	        }
+	      }
+
+	      sortSelectControl(selectControl, column.filterOrderBy);
+
+	      if (that.options.hideUnusedSelectOptions) {
+	        hideUnusedSelectOptions(selectControl, uniqueValues);
+	      }
+	    }
+	  });
+	}
+	function getFilterDataMethod(objFilterDataMethod, searchTerm) {
+	  var keys = Object.keys(objFilterDataMethod);
+
+	  for (var i = 0; i < keys.length; i++) {
+	    if (keys[i] === searchTerm) {
+	      return objFilterDataMethod[searchTerm];
+	    }
+	  }
+
+	  return null;
+	}
+	function createControls(that, header) {
+	  var addedFilterControl = false;
+	  var html;
+	  $.each(that.columns, function (_, column) {
+	    html = [];
+
+	    if (!column.visible) {
+	      return;
+	    }
+
+	    if (!column.filterControl && !that.options.filterControlContainer) {
+	      html.push('<div class="no-filter-control"></div>');
+	    } else if (that.options.filterControlContainer) {
+	      var $filterControls = $(".bootstrap-table-filter-control-".concat(column.field));
+	      $.each($filterControls, function (_, filterControl) {
+	        var $filterControl = $(filterControl);
+
+	        if (!$filterControl.is('[type=radio]')) {
+	          var placeholder = column.filterControlPlaceholder ? column.filterControlPlaceholder : '';
+	          $filterControl.attr('placeholder', placeholder).val(column.filterDefault);
+	        }
+
+	        $filterControl.attr('data-field', column.field);
+	      });
+	      addedFilterControl = true;
+	    } else {
+	      var nameControl = column.filterControl.toLowerCase();
+	      html.push('<div class="filter-control">');
+	      addedFilterControl = true;
+
+	      if (column.searchable && that.options.filterTemplate[nameControl]) {
+	        html.push(that.options.filterTemplate[nameControl](that, column.field, column.filterControlPlaceholder ? column.filterControlPlaceholder : '', column.filterDefault));
+	      }
+	    }
+
+	    if (!column.filterControl && '' !== column.filterDefault && 'undefined' !== typeof column.filterDefault) {
+	      if ($.isEmptyObject(that.filterColumnsPartial)) {
+	        that.filterColumnsPartial = {};
+	      }
+
+	      that.filterColumnsPartial[column.field] = column.filterDefault;
+	    }
+
+	    $.each(header.find('th'), function (i, th) {
+	      var $th = $(th);
+
+	      if ($th.data('field') === column.field) {
+	        $th.find('.fht-cell').append(html.join(''));
+	        return false;
+	      }
+	    });
+
+	    if (column.filterData && column.filterData.toLowerCase() !== 'column') {
+	      var filterDataType = getFilterDataMethod(
+	      /* eslint-disable no-use-before-define */
+	      filterDataMethods, column.filterData.substring(0, column.filterData.indexOf(':')));
+	      var filterDataSource;
+	      var selectControl;
+
+	      if (filterDataType) {
+	        filterDataSource = column.filterData.substring(column.filterData.indexOf(':') + 1, column.filterData.length);
+	        selectControl = header.find(".bootstrap-table-filter-control-".concat(escapeID(column.field)));
+	        addOptionToSelectControl(selectControl, '', column.filterControlPlaceholder, column.filterDefault);
+	        filterDataType(filterDataSource, selectControl, that.options.filterOrderBy, column.filterDefault);
+	      } else {
+	        throw new SyntaxError('Error. You should use any of these allowed filter data methods: var, obj, json, url, func.' + ' Use like this: var: {key: "value"}');
+	      }
+	    }
+	  });
+
+	  if (addedFilterControl) {
+	    header.off('keyup', 'input').on('keyup', 'input', function (_ref4, obj) {
+	      var currentTarget = _ref4.currentTarget,
+	          keyCode = _ref4.keyCode;
+	      syncControls(that); // Simulate enter key action from clear button
+
+	      keyCode = obj ? obj.keyCode : keyCode;
+
+	      if (that.options.searchOnEnterKey && keyCode !== 13) {
+	        return;
+	      }
+
+	      if ($.inArray(keyCode, [37, 38, 39, 40]) > -1) {
+	        return;
+	      }
+
+	      var $currentTarget = $(currentTarget);
+
+	      if ($currentTarget.is(':checkbox') || $currentTarget.is(':radio')) {
+	        return;
+	      }
+
+	      clearTimeout(currentTarget.timeoutId || 0);
+	      currentTarget.timeoutId = setTimeout(function () {
+	        that.onColumnSearch({
+	          currentTarget: currentTarget,
+	          keyCode: keyCode
+	        });
+	      }, that.options.searchTimeOut);
+	    });
+	    header.off('change', 'select:not(".ms-offscreen")').on('change', 'select:not(".ms-offscreen")', function (_ref5) {
+	      var currentTarget = _ref5.currentTarget,
+	          keyCode = _ref5.keyCode;
+	      syncControls(that);
+	      var $select = $(currentTarget);
+	      var value = $select.val();
+
+	      if (value && value.length > 0 && value.trim()) {
+	        $select.find('option[selected]').removeAttr('selected');
+	        $select.find('option[value="' + value + '"]').attr('selected', true);
+	      } else {
+	        $select.find('option[selected]').removeAttr('selected');
+	      }
+
+	      clearTimeout(currentTarget.timeoutId || 0);
+	      currentTarget.timeoutId = setTimeout(function () {
+	        that.onColumnSearch({
+	          currentTarget: currentTarget,
+	          keyCode: keyCode
+	        });
+	      }, that.options.searchTimeOut);
+	    });
+	    header.off('mouseup', 'input:not([type=radio])').on('mouseup', 'input:not([type=radio])', function (_ref6) {
+	      var currentTarget = _ref6.currentTarget,
+	          keyCode = _ref6.keyCode;
+	      var $input = $(currentTarget);
+	      var oldValue = $input.val();
+
+	      if (oldValue === '') {
+	        return;
+	      }
+
+	      setTimeout(function () {
+	        syncControls(that);
+	        var newValue = $input.val();
+
+	        if (newValue === '') {
+	          clearTimeout(currentTarget.timeoutId || 0);
+	          currentTarget.timeoutId = setTimeout(function () {
+	            that.onColumnSearch({
+	              currentTarget: currentTarget,
+	              keyCode: keyCode
+	            });
+	          }, that.options.searchTimeOut);
+	        }
+	      }, 1);
+	    });
+	    header.off('change', 'input[type=radio]').on('change', 'input[type=radio]', function (_ref7) {
+	      var currentTarget = _ref7.currentTarget,
+	          keyCode = _ref7.keyCode;
+	      clearTimeout(currentTarget.timeoutId || 0);
+	      currentTarget.timeoutId = setTimeout(function () {
+	        syncControls(that);
+	        that.onColumnSearch({
+	          currentTarget: currentTarget,
+	          keyCode: keyCode
+	        });
+	      }, that.options.searchTimeOut);
+	    });
+
+	    if (header.find('.date-filter-control').length > 0) {
+	      $.each(that.columns, function (i, _ref8) {
+	        var filterControl = _ref8.filterControl,
+	            field = _ref8.field,
+	            filterDatepickerOptions = _ref8.filterDatepickerOptions;
+
+	        if (filterControl !== undefined && filterControl.toLowerCase() === 'datepicker') {
+	          header.find(".date-filter-control.bootstrap-table-filter-control-".concat(field)).datepicker(filterDatepickerOptions).on('changeDate', function (_ref9) {
+	            var currentTarget = _ref9.currentTarget,
+	                keyCode = _ref9.keyCode;
+	            clearTimeout(currentTarget.timeoutId || 0);
+	            currentTarget.timeoutId = setTimeout(function () {
+	              syncControls(that);
+	              that.onColumnSearch({
+	                currentTarget: currentTarget,
+	                keyCode: keyCode
+	              });
+	            }, that.options.searchTimeOut);
+	          });
+	        }
+	      });
+	    }
+
+	    if (that.options.sidePagination !== 'server' && !that.options.height) {
+	      that.triggerSearch();
+	    }
+
+	    if (!that.options.filterControlVisible) {
+	      header.find('.filter-control, .no-filter-control').hide();
+	    }
+	  } else {
+	    header.find('.filter-control, .no-filter-control').hide();
+	  }
+
+	  that.trigger('created-controls');
+	}
+	function getDirectionOfSelectOptions(_alignment) {
+	  var alignment = _alignment === undefined ? 'left' : _alignment.toLowerCase();
+
+	  switch (alignment) {
+	    case 'left':
+	      return 'ltr';
+
+	    case 'right':
+	      return 'rtl';
+
+	    case 'auto':
+	      return 'auto';
+
+	    default:
+	      return 'ltr';
+	  }
+	}
+	function syncControls(that) {
+	  if (that.options.height) {
+	    var controlsTableHeader = that.$tableHeader.find(searchControls);
+	    that.$header.find(searchControls).each(function (_, control) {
+	      var $control = $(control);
+	      var controlClass = getElementClass($control);
+	      var foundControl = controlsTableHeader.filter(function (_, ele) {
+	        var eleClass = getElementClass($(ele));
+	        return controlClass === eleClass;
+	      });
+
+	      if (foundControl.length === 0) {
+	        return;
+	      }
+
+	      if ($control.is('select')) {
+	        $control.find('option:selected').removeAttr('selected');
+	        $control.find("option[value='".concat(foundControl.val(), "']")).attr('selected', true);
+	      } else {
+	        $control.val(foundControl.val());
+	      }
+	    });
+	  }
+	}
+	var filterDataMethods = {
+	  func: function func(filterDataSource, selectControl, filterOrderBy, selected) {
+	    var variableValues = window[filterDataSource].apply();
+
+	    for (var key in variableValues) {
+	      addOptionToSelectControl(selectControl, key, variableValues[key], selected);
+	    }
+
+	    sortSelectControl(selectControl, filterOrderBy);
+	  },
+	  obj: function obj(filterDataSource, selectControl, filterOrderBy, selected) {
+	    var objectKeys = filterDataSource.split('.');
+	    var variableName = objectKeys.shift();
+	    var variableValues = window[variableName];
+
+	    if (objectKeys.length > 0) {
+	      objectKeys.forEach(function (key) {
+	        variableValues = variableValues[key];
+	      });
+	    }
+
+	    for (var key in variableValues) {
+	      addOptionToSelectControl(selectControl, key, variableValues[key], selected);
+	    }
+
+	    sortSelectControl(selectControl, filterOrderBy);
+	  },
+	  var: function _var(filterDataSource, selectControl, filterOrderBy, selected) {
+	    var variableValues = window[filterDataSource];
+	    var isArray = Array.isArray(variableValues);
+
+	    for (var key in variableValues) {
+	      if (isArray) {
+	        addOptionToSelectControl(selectControl, variableValues[key], variableValues[key], selected);
+	      } else {
+	        addOptionToSelectControl(selectControl, key, variableValues[key], selected);
+	      }
+	    }
+
+	    sortSelectControl(selectControl, filterOrderBy);
+	  },
+	  url: function url(filterDataSource, selectControl, filterOrderBy, selected) {
+	    $.ajax({
+	      url: filterDataSource,
+	      dataType: 'json',
+	      success: function success(data) {
+	        for (var key in data) {
+	          addOptionToSelectControl(selectControl, key, data[key], selected);
+	        }
+
+	        sortSelectControl(selectControl, filterOrderBy);
+	      }
+	    });
+	  },
+	  json: function json(filterDataSource, selectControl, filterOrderBy, selected) {
+	    var variableValues = JSON.parse(filterDataSource);
+
+	    for (var key in variableValues) {
+	      addOptionToSelectControl(selectControl, key, variableValues[key], selected);
+	    }
+
+	    sortSelectControl(selectControl, filterOrderBy);
+	  }
+	};
+
+	var Utils$1 = $.fn.bootstrapTable.utils;
+	$.extend($.fn.bootstrapTable.defaults, {
+	  filterControl: false,
+	  filterControlVisible: true,
+	  onColumnSearch: function onColumnSearch(field, text) {
+	    return false;
+	  },
+	  onCreatedControls: function onCreatedControls() {
+	    return false;
+	  },
+	  alignmentSelectControlOptions: undefined,
+	  filterTemplate: {
+	    input: function input(that, field, placeholder, value) {
+	      return Utils$1.sprintf('<input type="search" class="form-control bootstrap-table-filter-control-%s search-input" style="width: 100%;" placeholder="%s" value="%s">', field, 'undefined' === typeof placeholder ? '' : placeholder, 'undefined' === typeof value ? '' : value);
+	    },
+	    select: function select(_ref, field) {
+	      var options = _ref.options;
+	      return Utils$1.sprintf('<select class="form-control bootstrap-table-filter-control-%s" style="width: 100%;" dir="%s"></select>', field, getDirectionOfSelectOptions(options.alignmentSelectControlOptions));
+	    },
+	    datepicker: function datepicker(that, field, value) {
+	      return Utils$1.sprintf('<input type="text" class="form-control date-filter-control bootstrap-table-filter-control-%s" style="width: 100%;" value="%s">', field, 'undefined' === typeof value ? '' : value);
+	    }
+	  },
+	  disableControlWhenSearch: false,
+	  searchOnEnterKey: false,
+	  showFilterControlSwitch: false,
+	  // internal variables
+	  valuesFilterControl: []
+	});
+	$.extend($.fn.bootstrapTable.columnDefaults, {
+	  filterControl: undefined,
+	  // input, select, datepicker
+	  filterDataCollector: undefined,
+	  filterData: undefined,
+	  filterDatepickerOptions: undefined,
+	  filterStrictSearch: false,
+	  filterStartsWithSearch: false,
+	  filterControlPlaceholder: '',
+	  filterDefault: '',
+	  filterOrderBy: 'asc' // asc || desc
+
+	});
+	$.extend($.fn.bootstrapTable.Constructor.EVENTS, {
+	  'column-search.bs.table': 'onColumnSearch',
+	  'created-controls.bs.table': 'onCreatedControls'
+	});
+	$.extend($.fn.bootstrapTable.defaults.icons, {
+	  clear: {
+	    bootstrap3: 'glyphicon-trash icon-clear'
+	  }[$.fn.bootstrapTable.theme] || 'fa-trash',
+	  filterControlSwitchHide: {
+	    bootstrap3: 'glyphicon-zoom-out icon-zoom-out',
+	    materialize: 'zoom_out'
+	  }[$.fn.bootstrapTable.theme] || 'fa-search-minus',
+	  filterControlSwitchShow: {
+	    bootstrap3: 'glyphicon-zoom-in icon-zoom-in',
+	    materialize: 'zoom_in'
+	  }[$.fn.bootstrapTable.theme] || 'fa-search-plus'
+	});
+	$.extend($.fn.bootstrapTable.locales, {
+	  formatFilterControlSwitch: function formatFilterControlSwitch() {
+	    return 'Hide/Show controls';
+	  },
+	  formatFilterControlSwitchHide: function formatFilterControlSwitchHide() {
+	    return 'Hide controls';
+	  },
+	  formatFilterControlSwitchShow: function formatFilterControlSwitchShow() {
+	    return 'Show controls';
+	  }
+	});
+	$.extend($.fn.bootstrapTable.defaults, $.fn.bootstrapTable.locales);
+	$.extend($.fn.bootstrapTable.defaults, {
+	  formatClearSearch: function formatClearSearch() {
+	    return 'Clear filters';
+	  }
+	});
+	$.fn.bootstrapTable.methods.push('triggerSearch');
+	$.fn.bootstrapTable.methods.push('clearFilterControl');
+	$.fn.bootstrapTable.methods.push('toggleFilterControl');
+
+	$.BootstrapTable =
+	/*#__PURE__*/
+	function (_$$BootstrapTable) {
+	  _inherits(_class, _$$BootstrapTable);
+
+	  function _class() {
+	    _classCallCheck(this, _class);
+
+	    return _possibleConstructorReturn(this, _getPrototypeOf(_class).apply(this, arguments));
+	  }
+
+	  _createClass(_class, [{
+	    key: "init",
+	    value: function init() {
+	      var _this = this;
+
+	      // Make sure that the filterControl option is set
+	      if (this.options.filterControl) {
+	        // Make sure that the internal variables are set correctly
+	        this.options.valuesFilterControl = [];
+	        this.$el.on('reset-view.bs.table', function () {
+	          // Create controls on $tableHeader if the height is set
+	          if (!_this.options.height) {
+	            return;
+	          } // Avoid recreate the controls
+
+
+	          var $controlContainer = getControlContainer(_this);
+
+	          if ($controlContainer.find('select').length > 0 || $controlContainer.find('input:not([type="checkbox"]):not([type="radio"])').length > 0) {
+	            return;
+	          }
+
+	          createControls(_this, $controlContainer);
+	        }).on('post-header.bs.table', function () {
+	          setValues(_this);
+	        }).on('post-body.bs.table', function () {
+	          if (_this.options.height && !_this.options.filterControlContainer) {
+	            fixHeaderCSS(_this);
+	          }
+
+	          _this.$tableLoading.css('top', _this.$header.outerHeight() + 1);
+	        }).on('column-switch.bs.table', function () {
+	          setValues(_this);
+	        }).on('load-success.bs.table', function () {
+	          _this.enableControls(true);
+	        }).on('load-error.bs.table', function () {
+	          _this.enableControls(true);
+	        });
+	      }
+
+	      _get(_getPrototypeOf(_class.prototype), "init", this).call(this);
+	    }
+	  }, {
+	    key: "initHeader",
+	    value: function initHeader() {
+	      _get(_getPrototypeOf(_class.prototype), "initHeader", this).call(this);
+
+	      if (!this.options.filterControl || this.options.height) {
+	        return;
+	      }
+
+	      createControls(this, getControlContainer(this));
+	    }
+	  }, {
+	    key: "initBody",
+	    value: function initBody() {
+	      _get(_getPrototypeOf(_class.prototype), "initBody", this).call(this);
+
+	      syncControls(this);
+	      initFilterSelectControls(this);
+	    }
+	  }, {
+	    key: "initSearch",
+	    value: function initSearch() {
+	      var _this2 = this;
+
+	      var that = this;
+	      var fp = $.isEmptyObject(that.filterColumnsPartial) ? null : that.filterColumnsPartial;
+
+	      _get(_getPrototypeOf(_class.prototype), "initSearch", this).call(this);
+
+	      if (this.options.sidePagination === 'server' || fp === null) {
+	        return;
+	      } // Check partial column filter
+
+
+	      that.data = fp ? that.data.filter(function (item, i) {
+	        var itemIsExpected = [];
+	        var keys1 = Object.keys(item);
+	        var keys2 = Object.keys(fp);
+	        var keys = keys1.concat(keys2.filter(function (item) {
+	          return !keys1.includes(item);
+	        }));
+	        keys.forEach(function (key) {
+	          var thisColumn = that.columns[that.fieldsColumnsIndex[key]];
+	          var fval = (fp[key] || '').toLowerCase();
+	          var value = Utils$1.getItemField(item, key, false);
+	          var tmpItemIsExpected;
+
+	          if (fval === '') {
+	            tmpItemIsExpected = true;
+	          } else {
+	            // Fix #142: search use formatted data
+	            if (thisColumn && thisColumn.searchFormatter) {
+	              value = $.fn.bootstrapTable.utils.calculateObjectValue(that.header, that.header.formatters[$.inArray(key, that.header.fields)], [value, item, i], value);
+	            }
+
+	            if ($.inArray(key, that.header.fields) !== -1) {
+	              if (value === undefined || value === null) {
+	                tmpItemIsExpected = false;
+	              } else if (_typeof(value) === 'object') {
+	                value.forEach(function (objectValue) {
+	                  if (tmpItemIsExpected) {
+	                    return;
+	                  }
+
+	                  if (_this2.options.searchAccentNeutralise) {
+	                    objectValue = Utils$1.normalizeAccent(objectValue);
+	                  }
+
+	                  tmpItemIsExpected = that.isValueExpected(fval, objectValue, thisColumn, key);
+	                });
+	              } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+	                if (_this2.options.searchAccentNeutralise) {
+	                  value = Utils$1.normalizeAccent(value);
+	                }
+
+	                tmpItemIsExpected = that.isValueExpected(fval, value, thisColumn, key);
+	              }
+	            }
+	          }
+
+	          itemIsExpected.push(tmpItemIsExpected);
+	        });
+	        return !itemIsExpected.includes(false);
+	      }) : that.data;
+	      that.unsortedData = _toConsumableArray(that.data);
+	    }
+	  }, {
+	    key: "isValueExpected",
+	    value: function isValueExpected(searchValue, value, column, key) {
+	      var tmpItemIsExpected = false;
+
+	      if (column.filterStrictSearch) {
+	        tmpItemIsExpected = value.toString().toLowerCase() === searchValue.toString().toLowerCase();
+	      } else if (column.filterStartsWithSearch) {
+	        tmpItemIsExpected = "".concat(value).toLowerCase().indexOf(searchValue) === 0;
+	      } else {
+	        tmpItemIsExpected = "".concat(value).toLowerCase().includes(searchValue);
+	      }
+
+	      var largerSmallerEqualsRegex = /(?:(<=|=>|=<|>=|>|<)(?:\s+)?(\d+)?|(\d+)?(\s+)?(<=|=>|=<|>=|>|<))/gm;
+	      var matches = largerSmallerEqualsRegex.exec(searchValue);
+
+	      if (matches) {
+	        var operator = matches[1] || "".concat(matches[5], "l");
+	        var comparisonValue = matches[2] || matches[3];
+	        var int = parseInt(value, 10);
+	        var comparisonInt = parseInt(comparisonValue, 10);
+
+	        switch (operator) {
+	          case '>':
+	          case '<l':
+	            tmpItemIsExpected = int > comparisonInt;
+	            break;
+
+	          case '<':
+	          case '>l':
+	            tmpItemIsExpected = int < comparisonInt;
+	            break;
+
+	          case '<=':
+	          case '=<':
+	          case '>=l':
+	          case '=>l':
+	            tmpItemIsExpected = int <= comparisonInt;
+	            break;
+
+	          case '>=':
+	          case '=>':
+	          case '<=l':
+	          case '=<l':
+	            tmpItemIsExpected = int >= comparisonInt;
+	            break;
+	        }
+	      }
+
+	      if (column.filterCustomSearch) {
+	        var customSearchResult = Utils$1.calculateObjectValue(this, column.filterCustomSearch, [searchValue, value, key, this.options.data], true);
+
+	        if (customSearchResult !== null) {
+	          tmpItemIsExpected = customSearchResult;
+	        }
+	      }
+
+	      return tmpItemIsExpected;
+	    }
+	  }, {
+	    key: "initColumnSearch",
+	    value: function initColumnSearch(filterColumnsDefaults) {
+	      copyValues(this);
+
+	      if (filterColumnsDefaults) {
+	        this.filterColumnsPartial = filterColumnsDefaults;
+	        this.updatePagination();
+
+	        for (var filter in filterColumnsDefaults) {
+	          this.trigger('column-search', filter, filterColumnsDefaults[filter]);
+	        }
+	      }
+	    }
+	  }, {
+	    key: "onColumnSearch",
+	    value: function onColumnSearch(_ref2) {
+	      var currentTarget = _ref2.currentTarget,
+	          keyCode = _ref2.keyCode;
+
+	      if ($.inArray(keyCode, [37, 38, 39, 40]) > -1) {
+	        return;
+	      }
+
+	      copyValues(this);
+	      var text = $.trim($(currentTarget).val());
+	      var $field = $(currentTarget).closest('[data-field]').data('field');
+	      this.trigger('column-search', $field, text);
+
+	      if ($.isEmptyObject(this.filterColumnsPartial)) {
+	        this.filterColumnsPartial = {};
+	      }
+
+	      if (text) {
+	        this.filterColumnsPartial[$field] = text;
+	      } else {
+	        delete this.filterColumnsPartial[$field];
+	      }
+
+	      this.options.pageNumber = 1;
+	      this.enableControls(false);
+	      this.onSearch({
+	        currentTarget: currentTarget
+	      }, false);
+	    }
+	  }, {
+	    key: "initToolbar",
+	    value: function initToolbar() {
+	      this.showToolbar = this.showToolbar || this.options.showFilterControlSwitch;
+	      this.showSearchClearButton = this.options.filterControl && this.options.showSearchClearButton;
+
+	      if (this.options.showFilterControlSwitch) {
+	        this.buttons = Object.assign(this.buttons, {
+	          filterControlSwitch: {
+	            'text': this.options.filterControlVisible ? this.options.formatFilterControlSwitchHide() : this.options.formatFilterControlSwitchShow(),
+	            'icon': this.options.filterControlVisible ? this.options.icons.filterControlSwitchHide : this.options.icons.filterControlSwitchShow,
+	            'event': this.toggleFilterControl,
+	            'attributes': {
+	              'aria-label': this.options.formatFilterControlSwitch(),
+	              'title': this.options.formatFilterControlSwitch()
+	            }
+	          }
+	        });
+	      }
+
+	      _get(_getPrototypeOf(_class.prototype), "initToolbar", this).call(this);
+	    }
+	  }, {
+	    key: "resetSearch",
+	    value: function resetSearch(text) {
+	      if (this.options.filterControl && this.options.showSearchClearButton) {
+	        this.clearFilterControl();
+	      }
+
+	      _get(_getPrototypeOf(_class.prototype), "resetSearch", this).call(this, text);
+	    }
+	  }, {
+	    key: "clearFilterControl",
+	    value: function clearFilterControl() {
+	      if (this.options.filterControl) {
+	        var that = this;
+	        var cookies = collectBootstrapCookies();
+	        var table = this.$el.closest('table');
+	        var controls = getSearchControls(that);
+	        var search = Utils$1.getSearchInput(this);
+	        var hasValues = false;
+	        var timeoutId = 0;
+	        $.each(that.options.valuesFilterControl, function (i, item) {
+	          hasValues = hasValues ? true : item.value !== '';
+	          item.value = '';
+	        });
+	        $.each(that.options.filterControls, function (i, item) {
+	          item.text = '';
+	        });
+	        setValues(that); // clear cookies once the filters are clean
+
+	        clearTimeout(timeoutId);
+	        timeoutId = setTimeout(function () {
+	          if (cookies && cookies.length > 0) {
+	            $.each(cookies, function (i, item) {
+	              if (that.deleteCookie !== undefined) {
+	                that.deleteCookie(item);
+	              }
+	            });
+	          }
+	        }, that.options.searchTimeOut); // If there is not any value in the controls exit this method
+
+	        if (!hasValues) {
+	          return;
+	        } // Clear each type of filter if it exists.
+	        // Requires the body to reload each time a type of filter is found because we never know
+	        // which ones are going to be present.
+
+
+	        if (controls.length > 0) {
+	          this.filterColumnsPartial = {};
+	          $(controls[0]).trigger(controls[0].tagName === 'INPUT' ? 'keyup' : 'change', {
+	            keyCode: 13
+	          });
+	        } else {
+	          return;
+	        }
+
+	        if (search.length > 0) {
+	          that.resetSearch();
+	        } // use the default sort order if it exists. do nothing if it does not
+
+
+	        if (that.options.sortName !== table.data('sortName') || that.options.sortOrder !== table.data('sortOrder')) {
+	          var sorter = this.$header.find(Utils$1.sprintf('[data-field="%s"]', $(controls[0]).closest('table').data('sortName')));
+
+	          if (sorter.length > 0) {
+	            that.onSort({
+	              type: 'keypress',
+	              currentTarget: sorter
+	            });
+	            $(sorter).find('.sortable').trigger('click');
+	          }
+	        }
+	      }
+	    }
+	  }, {
+	    key: "triggerSearch",
+	    value: function triggerSearch() {
+	      var searchControls = getSearchControls(this);
+	      searchControls.each(function () {
+	        var el = $(this);
+
+	        if (el.is('select')) {
+	          el.change();
+	        } else {
+	          el.keyup();
+	        }
+	      });
+	    }
+	  }, {
+	    key: "enableControls",
+	    value: function enableControls(enable) {
+	      if (this.options.disableControlWhenSearch && this.options.sidePagination === 'server') {
+	        var searchControls = getSearchControls(this);
+
+	        if (!enable) {
+	          searchControls.prop('disabled', 'disabled');
+	        } else {
+	          searchControls.removeProp('disabled');
+	        }
+	      }
+	    }
+	  }, {
+	    key: "toggleFilterControl",
+	    value: function toggleFilterControl() {
+	      this.options.filterControlVisible = !this.options.filterControlVisible;
+	      var $filterControls = getControlContainer(this).find('.filter-control, .no-filter-control');
+
+	      if (this.options.filterControlVisible) {
+	        $filterControls.show();
+	      } else {
+	        $filterControls.hide();
+	        this.clearFilterControl();
+	      }
+
+	      var icon = this.options.showButtonIcons ? this.options.filterControlVisible ? this.options.icons.filterControlSwitchHide : this.options.icons.filterControlSwitchShow : '';
+	      var text = this.options.showButtonText ? this.options.filterControlVisible ? this.options.formatFilterControlSwitchHide() : this.options.formatFilterControlSwitchShow() : '';
+	      this.$toolbar.find('>.columns').find('.filter-control-switch').html(Utils$1.sprintf(this.constants.html.icon, this.options.iconsPrefix, icon) + ' ' + text);
+	    }
+	  }]);
+
+	  return _class;
+	}($.BootstrapTable);
+
+})));
diff --git a/InvenTree/InvenTree/static/script/bootstrap/filter-control-utils.js b/InvenTree/InvenTree/static/script/bootstrap/filter-control-utils.js
new file mode 100644
index 0000000000..204efed32f
--- /dev/null
+++ b/InvenTree/InvenTree/static/script/bootstrap/filter-control-utils.js
@@ -0,0 +1,2361 @@
+(function (global, factory) {
+	typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('jquery')) :
+	typeof define === 'function' && define.amd ? define(['exports', 'jquery'], factory) :
+	(global = global || self, factory(global.BootstrapTable = {}, global.jQuery));
+}(this, (function (exports, $) { 'use strict';
+
+	$ = $ && Object.prototype.hasOwnProperty.call($, 'default') ? $['default'] : $;
+
+	var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
+
+	function createCommonjsModule(fn, module) {
+		return module = { exports: {} }, fn(module, module.exports), module.exports;
+	}
+
+	var check = function (it) {
+	  return it && it.Math == Math && it;
+	};
+
+	// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028
+	var global_1 =
+	  // eslint-disable-next-line no-undef
+	  check(typeof globalThis == 'object' && globalThis) ||
+	  check(typeof window == 'object' && window) ||
+	  check(typeof self == 'object' && self) ||
+	  check(typeof commonjsGlobal == 'object' && commonjsGlobal) ||
+	  // eslint-disable-next-line no-new-func
+	  Function('return this')();
+
+	var fails = function (exec) {
+	  try {
+	    return !!exec();
+	  } catch (error) {
+	    return true;
+	  }
+	};
+
+	// Thank's IE8 for his funny defineProperty
+	var descriptors = !fails(function () {
+	  return Object.defineProperty({}, 'a', { get: function () { return 7; } }).a != 7;
+	});
+
+	var nativePropertyIsEnumerable = {}.propertyIsEnumerable;
+	var getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
+
+	// Nashorn ~ JDK8 bug
+	var NASHORN_BUG = getOwnPropertyDescriptor && !nativePropertyIsEnumerable.call({ 1: 2 }, 1);
+
+	// `Object.prototype.propertyIsEnumerable` method implementation
+	// https://tc39.github.io/ecma262/#sec-object.prototype.propertyisenumerable
+	var f = NASHORN_BUG ? function propertyIsEnumerable(V) {
+	  var descriptor = getOwnPropertyDescriptor(this, V);
+	  return !!descriptor && descriptor.enumerable;
+	} : nativePropertyIsEnumerable;
+
+	var objectPropertyIsEnumerable = {
+		f: f
+	};
+
+	var createPropertyDescriptor = function (bitmap, value) {
+	  return {
+	    enumerable: !(bitmap & 1),
+	    configurable: !(bitmap & 2),
+	    writable: !(bitmap & 4),
+	    value: value
+	  };
+	};
+
+	var toString = {}.toString;
+
+	var classofRaw = function (it) {
+	  return toString.call(it).slice(8, -1);
+	};
+
+	var split = ''.split;
+
+	// fallback for non-array-like ES3 and non-enumerable old V8 strings
+	var indexedObject = fails(function () {
+	  // throws an error in rhino, see https://github.com/mozilla/rhino/issues/346
+	  // eslint-disable-next-line no-prototype-builtins
+	  return !Object('z').propertyIsEnumerable(0);
+	}) ? function (it) {
+	  return classofRaw(it) == 'String' ? split.call(it, '') : Object(it);
+	} : Object;
+
+	// `RequireObjectCoercible` abstract operation
+	// https://tc39.github.io/ecma262/#sec-requireobjectcoercible
+	var requireObjectCoercible = function (it) {
+	  if (it == undefined) throw TypeError("Can't call method on " + it);
+	  return it;
+	};
+
+	// toObject with fallback for non-array-like ES3 strings
+
+
+
+	var toIndexedObject = function (it) {
+	  return indexedObject(requireObjectCoercible(it));
+	};
+
+	var isObject = function (it) {
+	  return typeof it === 'object' ? it !== null : typeof it === 'function';
+	};
+
+	// `ToPrimitive` abstract operation
+	// https://tc39.github.io/ecma262/#sec-toprimitive
+	// instead of the ES6 spec version, we didn't implement @@toPrimitive case
+	// and the second argument - flag - preferred type is a string
+	var toPrimitive = function (input, PREFERRED_STRING) {
+	  if (!isObject(input)) return input;
+	  var fn, val;
+	  if (PREFERRED_STRING && typeof (fn = input.toString) == 'function' && !isObject(val = fn.call(input))) return val;
+	  if (typeof (fn = input.valueOf) == 'function' && !isObject(val = fn.call(input))) return val;
+	  if (!PREFERRED_STRING && typeof (fn = input.toString) == 'function' && !isObject(val = fn.call(input))) return val;
+	  throw TypeError("Can't convert object to primitive value");
+	};
+
+	var hasOwnProperty = {}.hasOwnProperty;
+
+	var has = function (it, key) {
+	  return hasOwnProperty.call(it, key);
+	};
+
+	var document$1 = global_1.document;
+	// typeof document.createElement is 'object' in old IE
+	var EXISTS = isObject(document$1) && isObject(document$1.createElement);
+
+	var documentCreateElement = function (it) {
+	  return EXISTS ? document$1.createElement(it) : {};
+	};
+
+	// Thank's IE8 for his funny defineProperty
+	var ie8DomDefine = !descriptors && !fails(function () {
+	  return Object.defineProperty(documentCreateElement('div'), 'a', {
+	    get: function () { return 7; }
+	  }).a != 7;
+	});
+
+	var nativeGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
+
+	// `Object.getOwnPropertyDescriptor` method
+	// https://tc39.github.io/ecma262/#sec-object.getownpropertydescriptor
+	var f$1 = descriptors ? nativeGetOwnPropertyDescriptor : function getOwnPropertyDescriptor(O, P) {
+	  O = toIndexedObject(O);
+	  P = toPrimitive(P, true);
+	  if (ie8DomDefine) try {
+	    return nativeGetOwnPropertyDescriptor(O, P);
+	  } catch (error) { /* empty */ }
+	  if (has(O, P)) return createPropertyDescriptor(!objectPropertyIsEnumerable.f.call(O, P), O[P]);
+	};
+
+	var objectGetOwnPropertyDescriptor = {
+		f: f$1
+	};
+
+	var anObject = function (it) {
+	  if (!isObject(it)) {
+	    throw TypeError(String(it) + ' is not an object');
+	  } return it;
+	};
+
+	var nativeDefineProperty = Object.defineProperty;
+
+	// `Object.defineProperty` method
+	// https://tc39.github.io/ecma262/#sec-object.defineproperty
+	var f$2 = descriptors ? nativeDefineProperty : function defineProperty(O, P, Attributes) {
+	  anObject(O);
+	  P = toPrimitive(P, true);
+	  anObject(Attributes);
+	  if (ie8DomDefine) try {
+	    return nativeDefineProperty(O, P, Attributes);
+	  } catch (error) { /* empty */ }
+	  if ('get' in Attributes || 'set' in Attributes) throw TypeError('Accessors not supported');
+	  if ('value' in Attributes) O[P] = Attributes.value;
+	  return O;
+	};
+
+	var objectDefineProperty = {
+		f: f$2
+	};
+
+	var createNonEnumerableProperty = descriptors ? function (object, key, value) {
+	  return objectDefineProperty.f(object, key, createPropertyDescriptor(1, value));
+	} : function (object, key, value) {
+	  object[key] = value;
+	  return object;
+	};
+
+	var setGlobal = function (key, value) {
+	  try {
+	    createNonEnumerableProperty(global_1, key, value);
+	  } catch (error) {
+	    global_1[key] = value;
+	  } return value;
+	};
+
+	var SHARED = '__core-js_shared__';
+	var store = global_1[SHARED] || setGlobal(SHARED, {});
+
+	var sharedStore = store;
+
+	var functionToString = Function.toString;
+
+	// this helper broken in `3.4.1-3.4.4`, so we can't use `shared` helper
+	if (typeof sharedStore.inspectSource != 'function') {
+	  sharedStore.inspectSource = function (it) {
+	    return functionToString.call(it);
+	  };
+	}
+
+	var inspectSource = sharedStore.inspectSource;
+
+	var WeakMap = global_1.WeakMap;
+
+	var nativeWeakMap = typeof WeakMap === 'function' && /native code/.test(inspectSource(WeakMap));
+
+	var shared = createCommonjsModule(function (module) {
+	(module.exports = function (key, value) {
+	  return sharedStore[key] || (sharedStore[key] = value !== undefined ? value : {});
+	})('versions', []).push({
+	  version: '3.6.0',
+	  mode:  'global',
+	  copyright: '© 2019 Denis Pushkarev (zloirock.ru)'
+	});
+	});
+
+	var id = 0;
+	var postfix = Math.random();
+
+	var uid = function (key) {
+	  return 'Symbol(' + String(key === undefined ? '' : key) + ')_' + (++id + postfix).toString(36);
+	};
+
+	var keys = shared('keys');
+
+	var sharedKey = function (key) {
+	  return keys[key] || (keys[key] = uid(key));
+	};
+
+	var hiddenKeys = {};
+
+	var WeakMap$1 = global_1.WeakMap;
+	var set, get, has$1;
+
+	var enforce = function (it) {
+	  return has$1(it) ? get(it) : set(it, {});
+	};
+
+	var getterFor = function (TYPE) {
+	  return function (it) {
+	    var state;
+	    if (!isObject(it) || (state = get(it)).type !== TYPE) {
+	      throw TypeError('Incompatible receiver, ' + TYPE + ' required');
+	    } return state;
+	  };
+	};
+
+	if (nativeWeakMap) {
+	  var store$1 = new WeakMap$1();
+	  var wmget = store$1.get;
+	  var wmhas = store$1.has;
+	  var wmset = store$1.set;
+	  set = function (it, metadata) {
+	    wmset.call(store$1, it, metadata);
+	    return metadata;
+	  };
+	  get = function (it) {
+	    return wmget.call(store$1, it) || {};
+	  };
+	  has$1 = function (it) {
+	    return wmhas.call(store$1, it);
+	  };
+	} else {
+	  var STATE = sharedKey('state');
+	  hiddenKeys[STATE] = true;
+	  set = function (it, metadata) {
+	    createNonEnumerableProperty(it, STATE, metadata);
+	    return metadata;
+	  };
+	  get = function (it) {
+	    return has(it, STATE) ? it[STATE] : {};
+	  };
+	  has$1 = function (it) {
+	    return has(it, STATE);
+	  };
+	}
+
+	var internalState = {
+	  set: set,
+	  get: get,
+	  has: has$1,
+	  enforce: enforce,
+	  getterFor: getterFor
+	};
+
+	var redefine = createCommonjsModule(function (module) {
+	var getInternalState = internalState.get;
+	var enforceInternalState = internalState.enforce;
+	var TEMPLATE = String(String).split('String');
+
+	(module.exports = function (O, key, value, options) {
+	  var unsafe = options ? !!options.unsafe : false;
+	  var simple = options ? !!options.enumerable : false;
+	  var noTargetGet = options ? !!options.noTargetGet : false;
+	  if (typeof value == 'function') {
+	    if (typeof key == 'string' && !has(value, 'name')) createNonEnumerableProperty(value, 'name', key);
+	    enforceInternalState(value).source = TEMPLATE.join(typeof key == 'string' ? key : '');
+	  }
+	  if (O === global_1) {
+	    if (simple) O[key] = value;
+	    else setGlobal(key, value);
+	    return;
+	  } else if (!unsafe) {
+	    delete O[key];
+	  } else if (!noTargetGet && O[key]) {
+	    simple = true;
+	  }
+	  if (simple) O[key] = value;
+	  else createNonEnumerableProperty(O, key, value);
+	// add fake Function#toString for correct work wrapped methods / constructors with methods like LoDash isNative
+	})(Function.prototype, 'toString', function toString() {
+	  return typeof this == 'function' && getInternalState(this).source || inspectSource(this);
+	});
+	});
+
+	var path = global_1;
+
+	var aFunction = function (variable) {
+	  return typeof variable == 'function' ? variable : undefined;
+	};
+
+	var getBuiltIn = function (namespace, method) {
+	  return arguments.length < 2 ? aFunction(path[namespace]) || aFunction(global_1[namespace])
+	    : path[namespace] && path[namespace][method] || global_1[namespace] && global_1[namespace][method];
+	};
+
+	var ceil = Math.ceil;
+	var floor = Math.floor;
+
+	// `ToInteger` abstract operation
+	// https://tc39.github.io/ecma262/#sec-tointeger
+	var toInteger = function (argument) {
+	  return isNaN(argument = +argument) ? 0 : (argument > 0 ? floor : ceil)(argument);
+	};
+
+	var min = Math.min;
+
+	// `ToLength` abstract operation
+	// https://tc39.github.io/ecma262/#sec-tolength
+	var toLength = function (argument) {
+	  return argument > 0 ? min(toInteger(argument), 0x1FFFFFFFFFFFFF) : 0; // 2 ** 53 - 1 == 9007199254740991
+	};
+
+	var max = Math.max;
+	var min$1 = Math.min;
+
+	// Helper for a popular repeating case of the spec:
+	// Let integer be ? ToInteger(index).
+	// If integer < 0, let result be max((length + integer), 0); else let result be min(integer, length).
+	var toAbsoluteIndex = function (index, length) {
+	  var integer = toInteger(index);
+	  return integer < 0 ? max(integer + length, 0) : min$1(integer, length);
+	};
+
+	// `Array.prototype.{ indexOf, includes }` methods implementation
+	var createMethod = function (IS_INCLUDES) {
+	  return function ($this, el, fromIndex) {
+	    var O = toIndexedObject($this);
+	    var length = toLength(O.length);
+	    var index = toAbsoluteIndex(fromIndex, length);
+	    var value;
+	    // Array#includes uses SameValueZero equality algorithm
+	    // eslint-disable-next-line no-self-compare
+	    if (IS_INCLUDES && el != el) while (length > index) {
+	      value = O[index++];
+	      // eslint-disable-next-line no-self-compare
+	      if (value != value) return true;
+	    // Array#indexOf ignores holes, Array#includes - not
+	    } else for (;length > index; index++) {
+	      if ((IS_INCLUDES || index in O) && O[index] === el) return IS_INCLUDES || index || 0;
+	    } return !IS_INCLUDES && -1;
+	  };
+	};
+
+	var arrayIncludes = {
+	  // `Array.prototype.includes` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.includes
+	  includes: createMethod(true),
+	  // `Array.prototype.indexOf` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.indexof
+	  indexOf: createMethod(false)
+	};
+
+	var indexOf = arrayIncludes.indexOf;
+
+
+	var objectKeysInternal = function (object, names) {
+	  var O = toIndexedObject(object);
+	  var i = 0;
+	  var result = [];
+	  var key;
+	  for (key in O) !has(hiddenKeys, key) && has(O, key) && result.push(key);
+	  // Don't enum bug & hidden keys
+	  while (names.length > i) if (has(O, key = names[i++])) {
+	    ~indexOf(result, key) || result.push(key);
+	  }
+	  return result;
+	};
+
+	// IE8- don't enum bug keys
+	var enumBugKeys = [
+	  'constructor',
+	  'hasOwnProperty',
+	  'isPrototypeOf',
+	  'propertyIsEnumerable',
+	  'toLocaleString',
+	  'toString',
+	  'valueOf'
+	];
+
+	var hiddenKeys$1 = enumBugKeys.concat('length', 'prototype');
+
+	// `Object.getOwnPropertyNames` method
+	// https://tc39.github.io/ecma262/#sec-object.getownpropertynames
+	var f$3 = Object.getOwnPropertyNames || function getOwnPropertyNames(O) {
+	  return objectKeysInternal(O, hiddenKeys$1);
+	};
+
+	var objectGetOwnPropertyNames = {
+		f: f$3
+	};
+
+	var f$4 = Object.getOwnPropertySymbols;
+
+	var objectGetOwnPropertySymbols = {
+		f: f$4
+	};
+
+	// all object keys, includes non-enumerable and symbols
+	var ownKeys = getBuiltIn('Reflect', 'ownKeys') || function ownKeys(it) {
+	  var keys = objectGetOwnPropertyNames.f(anObject(it));
+	  var getOwnPropertySymbols = objectGetOwnPropertySymbols.f;
+	  return getOwnPropertySymbols ? keys.concat(getOwnPropertySymbols(it)) : keys;
+	};
+
+	var copyConstructorProperties = function (target, source) {
+	  var keys = ownKeys(source);
+	  var defineProperty = objectDefineProperty.f;
+	  var getOwnPropertyDescriptor = objectGetOwnPropertyDescriptor.f;
+	  for (var i = 0; i < keys.length; i++) {
+	    var key = keys[i];
+	    if (!has(target, key)) defineProperty(target, key, getOwnPropertyDescriptor(source, key));
+	  }
+	};
+
+	var replacement = /#|\.prototype\./;
+
+	var isForced = function (feature, detection) {
+	  var value = data[normalize(feature)];
+	  return value == POLYFILL ? true
+	    : value == NATIVE ? false
+	    : typeof detection == 'function' ? fails(detection)
+	    : !!detection;
+	};
+
+	var normalize = isForced.normalize = function (string) {
+	  return String(string).replace(replacement, '.').toLowerCase();
+	};
+
+	var data = isForced.data = {};
+	var NATIVE = isForced.NATIVE = 'N';
+	var POLYFILL = isForced.POLYFILL = 'P';
+
+	var isForced_1 = isForced;
+
+	var getOwnPropertyDescriptor$1 = objectGetOwnPropertyDescriptor.f;
+
+
+
+
+
+
+	/*
+	  options.target      - name of the target object
+	  options.global      - target is the global object
+	  options.stat        - export as static methods of target
+	  options.proto       - export as prototype methods of target
+	  options.real        - real prototype method for the `pure` version
+	  options.forced      - export even if the native feature is available
+	  options.bind        - bind methods to the target, required for the `pure` version
+	  options.wrap        - wrap constructors to preventing global pollution, required for the `pure` version
+	  options.unsafe      - use the simple assignment of property instead of delete + defineProperty
+	  options.sham        - add a flag to not completely full polyfills
+	  options.enumerable  - export as enumerable property
+	  options.noTargetGet - prevent calling a getter on target
+	*/
+	var _export = function (options, source) {
+	  var TARGET = options.target;
+	  var GLOBAL = options.global;
+	  var STATIC = options.stat;
+	  var FORCED, target, key, targetProperty, sourceProperty, descriptor;
+	  if (GLOBAL) {
+	    target = global_1;
+	  } else if (STATIC) {
+	    target = global_1[TARGET] || setGlobal(TARGET, {});
+	  } else {
+	    target = (global_1[TARGET] || {}).prototype;
+	  }
+	  if (target) for (key in source) {
+	    sourceProperty = source[key];
+	    if (options.noTargetGet) {
+	      descriptor = getOwnPropertyDescriptor$1(target, key);
+	      targetProperty = descriptor && descriptor.value;
+	    } else targetProperty = target[key];
+	    FORCED = isForced_1(GLOBAL ? key : TARGET + (STATIC ? '.' : '#') + key, options.forced);
+	    // contained in target
+	    if (!FORCED && targetProperty !== undefined) {
+	      if (typeof sourceProperty === typeof targetProperty) continue;
+	      copyConstructorProperties(sourceProperty, targetProperty);
+	    }
+	    // add a flag to not completely full polyfills
+	    if (options.sham || (targetProperty && targetProperty.sham)) {
+	      createNonEnumerableProperty(sourceProperty, 'sham', true);
+	    }
+	    // extend global
+	    redefine(target, key, sourceProperty, options);
+	  }
+	};
+
+	// `IsArray` abstract operation
+	// https://tc39.github.io/ecma262/#sec-isarray
+	var isArray = Array.isArray || function isArray(arg) {
+	  return classofRaw(arg) == 'Array';
+	};
+
+	// `ToObject` abstract operation
+	// https://tc39.github.io/ecma262/#sec-toobject
+	var toObject = function (argument) {
+	  return Object(requireObjectCoercible(argument));
+	};
+
+	var createProperty = function (object, key, value) {
+	  var propertyKey = toPrimitive(key);
+	  if (propertyKey in object) objectDefineProperty.f(object, propertyKey, createPropertyDescriptor(0, value));
+	  else object[propertyKey] = value;
+	};
+
+	var nativeSymbol = !!Object.getOwnPropertySymbols && !fails(function () {
+	  // Chrome 38 Symbol has incorrect toString conversion
+	  // eslint-disable-next-line no-undef
+	  return !String(Symbol());
+	});
+
+	var useSymbolAsUid = nativeSymbol
+	  // eslint-disable-next-line no-undef
+	  && !Symbol.sham
+	  // eslint-disable-next-line no-undef
+	  && typeof Symbol() == 'symbol';
+
+	var WellKnownSymbolsStore = shared('wks');
+	var Symbol$1 = global_1.Symbol;
+	var createWellKnownSymbol = useSymbolAsUid ? Symbol$1 : uid;
+
+	var wellKnownSymbol = function (name) {
+	  if (!has(WellKnownSymbolsStore, name)) {
+	    if (nativeSymbol && has(Symbol$1, name)) WellKnownSymbolsStore[name] = Symbol$1[name];
+	    else WellKnownSymbolsStore[name] = createWellKnownSymbol('Symbol.' + name);
+	  } return WellKnownSymbolsStore[name];
+	};
+
+	var SPECIES = wellKnownSymbol('species');
+
+	// `ArraySpeciesCreate` abstract operation
+	// https://tc39.github.io/ecma262/#sec-arrayspeciescreate
+	var arraySpeciesCreate = function (originalArray, length) {
+	  var C;
+	  if (isArray(originalArray)) {
+	    C = originalArray.constructor;
+	    // cross-realm fallback
+	    if (typeof C == 'function' && (C === Array || isArray(C.prototype))) C = undefined;
+	    else if (isObject(C)) {
+	      C = C[SPECIES];
+	      if (C === null) C = undefined;
+	    }
+	  } return new (C === undefined ? Array : C)(length === 0 ? 0 : length);
+	};
+
+	var userAgent = getBuiltIn('navigator', 'userAgent') || '';
+
+	var process = global_1.process;
+	var versions = process && process.versions;
+	var v8 = versions && versions.v8;
+	var match, version;
+
+	if (v8) {
+	  match = v8.split('.');
+	  version = match[0] + match[1];
+	} else if (userAgent) {
+	  match = userAgent.match(/Edge\/(\d+)/);
+	  if (!match || match[1] >= 74) {
+	    match = userAgent.match(/Chrome\/(\d+)/);
+	    if (match) version = match[1];
+	  }
+	}
+
+	var v8Version = version && +version;
+
+	var SPECIES$1 = wellKnownSymbol('species');
+
+	var arrayMethodHasSpeciesSupport = function (METHOD_NAME) {
+	  // We can't use this feature detection in V8 since it causes
+	  // deoptimization and serious performance degradation
+	  // https://github.com/zloirock/core-js/issues/677
+	  return v8Version >= 51 || !fails(function () {
+	    var array = [];
+	    var constructor = array.constructor = {};
+	    constructor[SPECIES$1] = function () {
+	      return { foo: 1 };
+	    };
+	    return array[METHOD_NAME](Boolean).foo !== 1;
+	  });
+	};
+
+	var IS_CONCAT_SPREADABLE = wellKnownSymbol('isConcatSpreadable');
+	var MAX_SAFE_INTEGER = 0x1FFFFFFFFFFFFF;
+	var MAXIMUM_ALLOWED_INDEX_EXCEEDED = 'Maximum allowed index exceeded';
+
+	// We can't use this feature detection in V8 since it causes
+	// deoptimization and serious performance degradation
+	// https://github.com/zloirock/core-js/issues/679
+	var IS_CONCAT_SPREADABLE_SUPPORT = v8Version >= 51 || !fails(function () {
+	  var array = [];
+	  array[IS_CONCAT_SPREADABLE] = false;
+	  return array.concat()[0] !== array;
+	});
+
+	var SPECIES_SUPPORT = arrayMethodHasSpeciesSupport('concat');
+
+	var isConcatSpreadable = function (O) {
+	  if (!isObject(O)) return false;
+	  var spreadable = O[IS_CONCAT_SPREADABLE];
+	  return spreadable !== undefined ? !!spreadable : isArray(O);
+	};
+
+	var FORCED = !IS_CONCAT_SPREADABLE_SUPPORT || !SPECIES_SUPPORT;
+
+	// `Array.prototype.concat` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.concat
+	// with adding support of @@isConcatSpreadable and @@species
+	_export({ target: 'Array', proto: true, forced: FORCED }, {
+	  concat: function concat(arg) { // eslint-disable-line no-unused-vars
+	    var O = toObject(this);
+	    var A = arraySpeciesCreate(O, 0);
+	    var n = 0;
+	    var i, k, length, len, E;
+	    for (i = -1, length = arguments.length; i < length; i++) {
+	      E = i === -1 ? O : arguments[i];
+	      if (isConcatSpreadable(E)) {
+	        len = toLength(E.length);
+	        if (n + len > MAX_SAFE_INTEGER) throw TypeError(MAXIMUM_ALLOWED_INDEX_EXCEEDED);
+	        for (k = 0; k < len; k++, n++) if (k in E) createProperty(A, n, E[k]);
+	      } else {
+	        if (n >= MAX_SAFE_INTEGER) throw TypeError(MAXIMUM_ALLOWED_INDEX_EXCEEDED);
+	        createProperty(A, n++, E);
+	      }
+	    }
+	    A.length = n;
+	    return A;
+	  }
+	});
+
+	var aFunction$1 = function (it) {
+	  if (typeof it != 'function') {
+	    throw TypeError(String(it) + ' is not a function');
+	  } return it;
+	};
+
+	// optional / simple context binding
+	var bindContext = function (fn, that, length) {
+	  aFunction$1(fn);
+	  if (that === undefined) return fn;
+	  switch (length) {
+	    case 0: return function () {
+	      return fn.call(that);
+	    };
+	    case 1: return function (a) {
+	      return fn.call(that, a);
+	    };
+	    case 2: return function (a, b) {
+	      return fn.call(that, a, b);
+	    };
+	    case 3: return function (a, b, c) {
+	      return fn.call(that, a, b, c);
+	    };
+	  }
+	  return function (/* ...args */) {
+	    return fn.apply(that, arguments);
+	  };
+	};
+
+	var push = [].push;
+
+	// `Array.prototype.{ forEach, map, filter, some, every, find, findIndex }` methods implementation
+	var createMethod$1 = function (TYPE) {
+	  var IS_MAP = TYPE == 1;
+	  var IS_FILTER = TYPE == 2;
+	  var IS_SOME = TYPE == 3;
+	  var IS_EVERY = TYPE == 4;
+	  var IS_FIND_INDEX = TYPE == 6;
+	  var NO_HOLES = TYPE == 5 || IS_FIND_INDEX;
+	  return function ($this, callbackfn, that, specificCreate) {
+	    var O = toObject($this);
+	    var self = indexedObject(O);
+	    var boundFunction = bindContext(callbackfn, that, 3);
+	    var length = toLength(self.length);
+	    var index = 0;
+	    var create = specificCreate || arraySpeciesCreate;
+	    var target = IS_MAP ? create($this, length) : IS_FILTER ? create($this, 0) : undefined;
+	    var value, result;
+	    for (;length > index; index++) if (NO_HOLES || index in self) {
+	      value = self[index];
+	      result = boundFunction(value, index, O);
+	      if (TYPE) {
+	        if (IS_MAP) target[index] = result; // map
+	        else if (result) switch (TYPE) {
+	          case 3: return true;              // some
+	          case 5: return value;             // find
+	          case 6: return index;             // findIndex
+	          case 2: push.call(target, value); // filter
+	        } else if (IS_EVERY) return false;  // every
+	      }
+	    }
+	    return IS_FIND_INDEX ? -1 : IS_SOME || IS_EVERY ? IS_EVERY : target;
+	  };
+	};
+
+	var arrayIteration = {
+	  // `Array.prototype.forEach` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.foreach
+	  forEach: createMethod$1(0),
+	  // `Array.prototype.map` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.map
+	  map: createMethod$1(1),
+	  // `Array.prototype.filter` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.filter
+	  filter: createMethod$1(2),
+	  // `Array.prototype.some` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.some
+	  some: createMethod$1(3),
+	  // `Array.prototype.every` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.every
+	  every: createMethod$1(4),
+	  // `Array.prototype.find` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.find
+	  find: createMethod$1(5),
+	  // `Array.prototype.findIndex` method
+	  // https://tc39.github.io/ecma262/#sec-array.prototype.findIndex
+	  findIndex: createMethod$1(6)
+	};
+
+	var $filter = arrayIteration.filter;
+
+
+
+	var HAS_SPECIES_SUPPORT = arrayMethodHasSpeciesSupport('filter');
+	// Edge 14- issue
+	var USES_TO_LENGTH = HAS_SPECIES_SUPPORT && !fails(function () {
+	  [].filter.call({ length: -1, 0: 1 }, function (it) { throw it; });
+	});
+
+	// `Array.prototype.filter` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.filter
+	// with adding support of @@species
+	_export({ target: 'Array', proto: true, forced: !HAS_SPECIES_SUPPORT || !USES_TO_LENGTH }, {
+	  filter: function filter(callbackfn /* , thisArg */) {
+	    return $filter(this, callbackfn, arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	// `Object.keys` method
+	// https://tc39.github.io/ecma262/#sec-object.keys
+	var objectKeys = Object.keys || function keys(O) {
+	  return objectKeysInternal(O, enumBugKeys);
+	};
+
+	// `Object.defineProperties` method
+	// https://tc39.github.io/ecma262/#sec-object.defineproperties
+	var objectDefineProperties = descriptors ? Object.defineProperties : function defineProperties(O, Properties) {
+	  anObject(O);
+	  var keys = objectKeys(Properties);
+	  var length = keys.length;
+	  var index = 0;
+	  var key;
+	  while (length > index) objectDefineProperty.f(O, key = keys[index++], Properties[key]);
+	  return O;
+	};
+
+	var html = getBuiltIn('document', 'documentElement');
+
+	var GT = '>';
+	var LT = '<';
+	var PROTOTYPE = 'prototype';
+	var SCRIPT = 'script';
+	var IE_PROTO = sharedKey('IE_PROTO');
+
+	var EmptyConstructor = function () { /* empty */ };
+
+	var scriptTag = function (content) {
+	  return LT + SCRIPT + GT + content + LT + '/' + SCRIPT + GT;
+	};
+
+	// Create object with fake `null` prototype: use ActiveX Object with cleared prototype
+	var NullProtoObjectViaActiveX = function (activeXDocument) {
+	  activeXDocument.write(scriptTag(''));
+	  activeXDocument.close();
+	  var temp = activeXDocument.parentWindow.Object;
+	  activeXDocument = null; // avoid memory leak
+	  return temp;
+	};
+
+	// Create object with fake `null` prototype: use iframe Object with cleared prototype
+	var NullProtoObjectViaIFrame = function () {
+	  // Thrash, waste and sodomy: IE GC bug
+	  var iframe = documentCreateElement('iframe');
+	  var JS = 'java' + SCRIPT + ':';
+	  var iframeDocument;
+	  iframe.style.display = 'none';
+	  html.appendChild(iframe);
+	  // https://github.com/zloirock/core-js/issues/475
+	  iframe.src = String(JS);
+	  iframeDocument = iframe.contentWindow.document;
+	  iframeDocument.open();
+	  iframeDocument.write(scriptTag('document.F=Object'));
+	  iframeDocument.close();
+	  return iframeDocument.F;
+	};
+
+	// Check for document.domain and active x support
+	// No need to use active x approach when document.domain is not set
+	// see https://github.com/es-shims/es5-shim/issues/150
+	// variation of https://github.com/kitcambridge/es5-shim/commit/4f738ac066346
+	// avoid IE GC bug
+	var activeXDocument;
+	var NullProtoObject = function () {
+	  try {
+	    /* global ActiveXObject */
+	    activeXDocument = document.domain && new ActiveXObject('htmlfile');
+	  } catch (error) { /* ignore */ }
+	  NullProtoObject = activeXDocument ? NullProtoObjectViaActiveX(activeXDocument) : NullProtoObjectViaIFrame();
+	  var length = enumBugKeys.length;
+	  while (length--) delete NullProtoObject[PROTOTYPE][enumBugKeys[length]];
+	  return NullProtoObject();
+	};
+
+	hiddenKeys[IE_PROTO] = true;
+
+	// `Object.create` method
+	// https://tc39.github.io/ecma262/#sec-object.create
+	var objectCreate = Object.create || function create(O, Properties) {
+	  var result;
+	  if (O !== null) {
+	    EmptyConstructor[PROTOTYPE] = anObject(O);
+	    result = new EmptyConstructor();
+	    EmptyConstructor[PROTOTYPE] = null;
+	    // add "__proto__" for Object.getPrototypeOf polyfill
+	    result[IE_PROTO] = O;
+	  } else result = NullProtoObject();
+	  return Properties === undefined ? result : objectDefineProperties(result, Properties);
+	};
+
+	var UNSCOPABLES = wellKnownSymbol('unscopables');
+	var ArrayPrototype = Array.prototype;
+
+	// Array.prototype[@@unscopables]
+	// https://tc39.github.io/ecma262/#sec-array.prototype-@@unscopables
+	if (ArrayPrototype[UNSCOPABLES] == undefined) {
+	  objectDefineProperty.f(ArrayPrototype, UNSCOPABLES, {
+	    configurable: true,
+	    value: objectCreate(null)
+	  });
+	}
+
+	// add a key to Array.prototype[@@unscopables]
+	var addToUnscopables = function (key) {
+	  ArrayPrototype[UNSCOPABLES][key] = true;
+	};
+
+	var $find = arrayIteration.find;
+
+
+	var FIND = 'find';
+	var SKIPS_HOLES = true;
+
+	// Shouldn't skip holes
+	if (FIND in []) Array(1)[FIND](function () { SKIPS_HOLES = false; });
+
+	// `Array.prototype.find` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.find
+	_export({ target: 'Array', proto: true, forced: SKIPS_HOLES }, {
+	  find: function find(callbackfn /* , that = undefined */) {
+	    return $find(this, callbackfn, arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	// https://tc39.github.io/ecma262/#sec-array.prototype-@@unscopables
+	addToUnscopables(FIND);
+
+	var $includes = arrayIncludes.includes;
+
+
+	// `Array.prototype.includes` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.includes
+	_export({ target: 'Array', proto: true }, {
+	  includes: function includes(el /* , fromIndex = 0 */) {
+	    return $includes(this, el, arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	// https://tc39.github.io/ecma262/#sec-array.prototype-@@unscopables
+	addToUnscopables('includes');
+
+	var sloppyArrayMethod = function (METHOD_NAME, argument) {
+	  var method = [][METHOD_NAME];
+	  return !method || !fails(function () {
+	    // eslint-disable-next-line no-useless-call,no-throw-literal
+	    method.call(null, argument || function () { throw 1; }, 1);
+	  });
+	};
+
+	var $indexOf = arrayIncludes.indexOf;
+
+
+	var nativeIndexOf = [].indexOf;
+
+	var NEGATIVE_ZERO = !!nativeIndexOf && 1 / [1].indexOf(1, -0) < 0;
+	var SLOPPY_METHOD = sloppyArrayMethod('indexOf');
+
+	// `Array.prototype.indexOf` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.indexof
+	_export({ target: 'Array', proto: true, forced: NEGATIVE_ZERO || SLOPPY_METHOD }, {
+	  indexOf: function indexOf(searchElement /* , fromIndex = 0 */) {
+	    return NEGATIVE_ZERO
+	      // convert -0 to +0
+	      ? nativeIndexOf.apply(this, arguments) || 0
+	      : $indexOf(this, searchElement, arguments.length > 1 ? arguments[1] : undefined);
+	  }
+	});
+
+	var nativeJoin = [].join;
+
+	var ES3_STRINGS = indexedObject != Object;
+	var SLOPPY_METHOD$1 = sloppyArrayMethod('join', ',');
+
+	// `Array.prototype.join` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.join
+	_export({ target: 'Array', proto: true, forced: ES3_STRINGS || SLOPPY_METHOD$1 }, {
+	  join: function join(separator) {
+	    return nativeJoin.call(toIndexedObject(this), separator === undefined ? ',' : separator);
+	  }
+	});
+
+	var test = [];
+	var nativeSort = test.sort;
+
+	// IE8-
+	var FAILS_ON_UNDEFINED = fails(function () {
+	  test.sort(undefined);
+	});
+	// V8 bug
+	var FAILS_ON_NULL = fails(function () {
+	  test.sort(null);
+	});
+	// Old WebKit
+	var SLOPPY_METHOD$2 = sloppyArrayMethod('sort');
+
+	var FORCED$1 = FAILS_ON_UNDEFINED || !FAILS_ON_NULL || SLOPPY_METHOD$2;
+
+	// `Array.prototype.sort` method
+	// https://tc39.github.io/ecma262/#sec-array.prototype.sort
+	_export({ target: 'Array', proto: true, forced: FORCED$1 }, {
+	  sort: function sort(comparefn) {
+	    return comparefn === undefined
+	      ? nativeSort.call(toObject(this))
+	      : nativeSort.call(toObject(this), aFunction$1(comparefn));
+	  }
+	});
+
+	var FAILS_ON_PRIMITIVES = fails(function () { objectKeys(1); });
+
+	// `Object.keys` method
+	// https://tc39.github.io/ecma262/#sec-object.keys
+	_export({ target: 'Object', stat: true, forced: FAILS_ON_PRIMITIVES }, {
+	  keys: function keys(it) {
+	    return objectKeys(toObject(it));
+	  }
+	});
+
+	var TO_STRING_TAG = wellKnownSymbol('toStringTag');
+	var test$1 = {};
+
+	test$1[TO_STRING_TAG] = 'z';
+
+	var toStringTagSupport = String(test$1) === '[object z]';
+
+	var TO_STRING_TAG$1 = wellKnownSymbol('toStringTag');
+	// ES3 wrong here
+	var CORRECT_ARGUMENTS = classofRaw(function () { return arguments; }()) == 'Arguments';
+
+	// fallback for IE11 Script Access Denied error
+	var tryGet = function (it, key) {
+	  try {
+	    return it[key];
+	  } catch (error) { /* empty */ }
+	};
+
+	// getting tag from ES6+ `Object.prototype.toString`
+	var classof = toStringTagSupport ? classofRaw : function (it) {
+	  var O, tag, result;
+	  return it === undefined ? 'Undefined' : it === null ? 'Null'
+	    // @@toStringTag case
+	    : typeof (tag = tryGet(O = Object(it), TO_STRING_TAG$1)) == 'string' ? tag
+	    // builtinTag case
+	    : CORRECT_ARGUMENTS ? classofRaw(O)
+	    // ES3 arguments fallback
+	    : (result = classofRaw(O)) == 'Object' && typeof O.callee == 'function' ? 'Arguments' : result;
+	};
+
+	// `Object.prototype.toString` method implementation
+	// https://tc39.github.io/ecma262/#sec-object.prototype.tostring
+	var objectToString = toStringTagSupport ? {}.toString : function toString() {
+	  return '[object ' + classof(this) + ']';
+	};
+
+	// `Object.prototype.toString` method
+	// https://tc39.github.io/ecma262/#sec-object.prototype.tostring
+	if (!toStringTagSupport) {
+	  redefine(Object.prototype, 'toString', objectToString, { unsafe: true });
+	}
+
+	// `RegExp.prototype.flags` getter implementation
+	// https://tc39.github.io/ecma262/#sec-get-regexp.prototype.flags
+	var regexpFlags = function () {
+	  var that = anObject(this);
+	  var result = '';
+	  if (that.global) result += 'g';
+	  if (that.ignoreCase) result += 'i';
+	  if (that.multiline) result += 'm';
+	  if (that.dotAll) result += 's';
+	  if (that.unicode) result += 'u';
+	  if (that.sticky) result += 'y';
+	  return result;
+	};
+
+	// babel-minify transpiles RegExp('a', 'y') -> /a/y and it causes SyntaxError,
+	// so we use an intermediate function.
+	function RE(s, f) {
+	  return RegExp(s, f);
+	}
+
+	var UNSUPPORTED_Y = fails(function () {
+	  // babel-minify transpiles RegExp('a', 'y') -> /a/y and it causes SyntaxError
+	  var re = RE('a', 'y');
+	  re.lastIndex = 2;
+	  return re.exec('abcd') != null;
+	});
+
+	var BROKEN_CARET = fails(function () {
+	  // https://bugzilla.mozilla.org/show_bug.cgi?id=773687
+	  var re = RE('^r', 'gy');
+	  re.lastIndex = 2;
+	  return re.exec('str') != null;
+	});
+
+	var regexpStickyHelpers = {
+		UNSUPPORTED_Y: UNSUPPORTED_Y,
+		BROKEN_CARET: BROKEN_CARET
+	};
+
+	var nativeExec = RegExp.prototype.exec;
+	// This always refers to the native implementation, because the
+	// String#replace polyfill uses ./fix-regexp-well-known-symbol-logic.js,
+	// which loads this file before patching the method.
+	var nativeReplace = String.prototype.replace;
+
+	var patchedExec = nativeExec;
+
+	var UPDATES_LAST_INDEX_WRONG = (function () {
+	  var re1 = /a/;
+	  var re2 = /b*/g;
+	  nativeExec.call(re1, 'a');
+	  nativeExec.call(re2, 'a');
+	  return re1.lastIndex !== 0 || re2.lastIndex !== 0;
+	})();
+
+	var UNSUPPORTED_Y$1 = regexpStickyHelpers.UNSUPPORTED_Y || regexpStickyHelpers.BROKEN_CARET;
+
+	// nonparticipating capturing group, copied from es5-shim's String#split patch.
+	var NPCG_INCLUDED = /()??/.exec('')[1] !== undefined;
+
+	var PATCH = UPDATES_LAST_INDEX_WRONG || NPCG_INCLUDED || UNSUPPORTED_Y$1;
+
+	if (PATCH) {
+	  patchedExec = function exec(str) {
+	    var re = this;
+	    var lastIndex, reCopy, match, i;
+	    var sticky = UNSUPPORTED_Y$1 && re.sticky;
+	    var flags = regexpFlags.call(re);
+	    var source = re.source;
+	    var charsAdded = 0;
+	    var strCopy = str;
+
+	    if (sticky) {
+	      flags = flags.replace('y', '');
+	      if (flags.indexOf('g') === -1) {
+	        flags += 'g';
+	      }
+
+	      strCopy = String(str).slice(re.lastIndex);
+	      // Support anchored sticky behavior.
+	      if (re.lastIndex > 0 && (!re.multiline || re.multiline && str[re.lastIndex - 1] !== '\n')) {
+	        source = '(?: ' + source + ')';
+	        strCopy = ' ' + strCopy;
+	        charsAdded++;
+	      }
+	      // ^(? + rx + ) is needed, in combination with some str slicing, to
+	      // simulate the 'y' flag.
+	      reCopy = new RegExp('^(?:' + source + ')', flags);
+	    }
+
+	    if (NPCG_INCLUDED) {
+	      reCopy = new RegExp('^' + source + '$(?!\\s)', flags);
+	    }
+	    if (UPDATES_LAST_INDEX_WRONG) lastIndex = re.lastIndex;
+
+	    match = nativeExec.call(sticky ? reCopy : re, strCopy);
+
+	    if (sticky) {
+	      if (match) {
+	        match.input = match.input.slice(charsAdded);
+	        match[0] = match[0].slice(charsAdded);
+	        match.index = re.lastIndex;
+	        re.lastIndex += match[0].length;
+	      } else re.lastIndex = 0;
+	    } else if (UPDATES_LAST_INDEX_WRONG && match) {
+	      re.lastIndex = re.global ? match.index + match[0].length : lastIndex;
+	    }
+	    if (NPCG_INCLUDED && match && match.length > 1) {
+	      // Fix browsers whose `exec` methods don't consistently return `undefined`
+	      // for NPCG, like IE8. NOTE: This doesn' work for /(.?)?/
+	      nativeReplace.call(match[0], reCopy, function () {
+	        for (i = 1; i < arguments.length - 2; i++) {
+	          if (arguments[i] === undefined) match[i] = undefined;
+	        }
+	      });
+	    }
+
+	    return match;
+	  };
+	}
+
+	var regexpExec = patchedExec;
+
+	_export({ target: 'RegExp', proto: true, forced: /./.exec !== regexpExec }, {
+	  exec: regexpExec
+	});
+
+	var TO_STRING = 'toString';
+	var RegExpPrototype = RegExp.prototype;
+	var nativeToString = RegExpPrototype[TO_STRING];
+
+	var NOT_GENERIC = fails(function () { return nativeToString.call({ source: 'a', flags: 'b' }) != '/a/b'; });
+	// FF44- RegExp#toString has a wrong name
+	var INCORRECT_NAME = nativeToString.name != TO_STRING;
+
+	// `RegExp.prototype.toString` method
+	// https://tc39.github.io/ecma262/#sec-regexp.prototype.tostring
+	if (NOT_GENERIC || INCORRECT_NAME) {
+	  redefine(RegExp.prototype, TO_STRING, function toString() {
+	    var R = anObject(this);
+	    var p = String(R.source);
+	    var rf = R.flags;
+	    var f = String(rf === undefined && R instanceof RegExp && !('flags' in RegExpPrototype) ? regexpFlags.call(R) : rf);
+	    return '/' + p + '/' + f;
+	  }, { unsafe: true });
+	}
+
+	var SPECIES$2 = wellKnownSymbol('species');
+
+	var REPLACE_SUPPORTS_NAMED_GROUPS = !fails(function () {
+	  // #replace needs built-in support for named groups.
+	  // #match works fine because it just return the exec results, even if it has
+	  // a "grops" property.
+	  var re = /./;
+	  re.exec = function () {
+	    var result = [];
+	    result.groups = { a: '7' };
+	    return result;
+	  };
+	  return ''.replace(re, '$<a>') !== '7';
+	});
+
+	// IE <= 11 replaces $0 with the whole match, as if it was $&
+	// https://stackoverflow.com/questions/6024666/getting-ie-to-replace-a-regex-with-the-literal-string-0
+	var REPLACE_KEEPS_$0 = (function () {
+	  return 'a'.replace(/./, '$0') === '$0';
+	})();
+
+	// Chrome 51 has a buggy "split" implementation when RegExp#exec !== nativeExec
+	// Weex JS has frozen built-in prototypes, so use try / catch wrapper
+	var SPLIT_WORKS_WITH_OVERWRITTEN_EXEC = !fails(function () {
+	  var re = /(?:)/;
+	  var originalExec = re.exec;
+	  re.exec = function () { return originalExec.apply(this, arguments); };
+	  var result = 'ab'.split(re);
+	  return result.length !== 2 || result[0] !== 'a' || result[1] !== 'b';
+	});
+
+	var fixRegexpWellKnownSymbolLogic = function (KEY, length, exec, sham) {
+	  var SYMBOL = wellKnownSymbol(KEY);
+
+	  var DELEGATES_TO_SYMBOL = !fails(function () {
+	    // String methods call symbol-named RegEp methods
+	    var O = {};
+	    O[SYMBOL] = function () { return 7; };
+	    return ''[KEY](O) != 7;
+	  });
+
+	  var DELEGATES_TO_EXEC = DELEGATES_TO_SYMBOL && !fails(function () {
+	    // Symbol-named RegExp methods call .exec
+	    var execCalled = false;
+	    var re = /a/;
+
+	    if (KEY === 'split') {
+	      // We can't use real regex here since it causes deoptimization
+	      // and serious performance degradation in V8
+	      // https://github.com/zloirock/core-js/issues/306
+	      re = {};
+	      // RegExp[@@split] doesn't call the regex's exec method, but first creates
+	      // a new one. We need to return the patched regex when creating the new one.
+	      re.constructor = {};
+	      re.constructor[SPECIES$2] = function () { return re; };
+	      re.flags = '';
+	      re[SYMBOL] = /./[SYMBOL];
+	    }
+
+	    re.exec = function () { execCalled = true; return null; };
+
+	    re[SYMBOL]('');
+	    return !execCalled;
+	  });
+
+	  if (
+	    !DELEGATES_TO_SYMBOL ||
+	    !DELEGATES_TO_EXEC ||
+	    (KEY === 'replace' && !(REPLACE_SUPPORTS_NAMED_GROUPS && REPLACE_KEEPS_$0)) ||
+	    (KEY === 'split' && !SPLIT_WORKS_WITH_OVERWRITTEN_EXEC)
+	  ) {
+	    var nativeRegExpMethod = /./[SYMBOL];
+	    var methods = exec(SYMBOL, ''[KEY], function (nativeMethod, regexp, str, arg2, forceStringMethod) {
+	      if (regexp.exec === regexpExec) {
+	        if (DELEGATES_TO_SYMBOL && !forceStringMethod) {
+	          // The native String method already delegates to @@method (this
+	          // polyfilled function), leasing to infinite recursion.
+	          // We avoid it by directly calling the native @@method method.
+	          return { done: true, value: nativeRegExpMethod.call(regexp, str, arg2) };
+	        }
+	        return { done: true, value: nativeMethod.call(str, regexp, arg2) };
+	      }
+	      return { done: false };
+	    }, { REPLACE_KEEPS_$0: REPLACE_KEEPS_$0 });
+	    var stringMethod = methods[0];
+	    var regexMethod = methods[1];
+
+	    redefine(String.prototype, KEY, stringMethod);
+	    redefine(RegExp.prototype, SYMBOL, length == 2
+	      // 21.2.5.8 RegExp.prototype[@@replace](string, replaceValue)
+	      // 21.2.5.11 RegExp.prototype[@@split](string, limit)
+	      ? function (string, arg) { return regexMethod.call(string, this, arg); }
+	      // 21.2.5.6 RegExp.prototype[@@match](string)
+	      // 21.2.5.9 RegExp.prototype[@@search](string)
+	      : function (string) { return regexMethod.call(string, this); }
+	    );
+	  }
+
+	  if (sham) createNonEnumerableProperty(RegExp.prototype[SYMBOL], 'sham', true);
+	};
+
+	// `String.prototype.{ codePointAt, at }` methods implementation
+	var createMethod$2 = function (CONVERT_TO_STRING) {
+	  return function ($this, pos) {
+	    var S = String(requireObjectCoercible($this));
+	    var position = toInteger(pos);
+	    var size = S.length;
+	    var first, second;
+	    if (position < 0 || position >= size) return CONVERT_TO_STRING ? '' : undefined;
+	    first = S.charCodeAt(position);
+	    return first < 0xD800 || first > 0xDBFF || position + 1 === size
+	      || (second = S.charCodeAt(position + 1)) < 0xDC00 || second > 0xDFFF
+	        ? CONVERT_TO_STRING ? S.charAt(position) : first
+	        : CONVERT_TO_STRING ? S.slice(position, position + 2) : (first - 0xD800 << 10) + (second - 0xDC00) + 0x10000;
+	  };
+	};
+
+	var stringMultibyte = {
+	  // `String.prototype.codePointAt` method
+	  // https://tc39.github.io/ecma262/#sec-string.prototype.codepointat
+	  codeAt: createMethod$2(false),
+	  // `String.prototype.at` method
+	  // https://github.com/mathiasbynens/String.prototype.at
+	  charAt: createMethod$2(true)
+	};
+
+	var charAt = stringMultibyte.charAt;
+
+	// `AdvanceStringIndex` abstract operation
+	// https://tc39.github.io/ecma262/#sec-advancestringindex
+	var advanceStringIndex = function (S, index, unicode) {
+	  return index + (unicode ? charAt(S, index).length : 1);
+	};
+
+	// `RegExpExec` abstract operation
+	// https://tc39.github.io/ecma262/#sec-regexpexec
+	var regexpExecAbstract = function (R, S) {
+	  var exec = R.exec;
+	  if (typeof exec === 'function') {
+	    var result = exec.call(R, S);
+	    if (typeof result !== 'object') {
+	      throw TypeError('RegExp exec method returned something other than an Object or null');
+	    }
+	    return result;
+	  }
+
+	  if (classofRaw(R) !== 'RegExp') {
+	    throw TypeError('RegExp#exec called on incompatible receiver');
+	  }
+
+	  return regexpExec.call(R, S);
+	};
+
+	// @@match logic
+	fixRegexpWellKnownSymbolLogic('match', 1, function (MATCH, nativeMatch, maybeCallNative) {
+	  return [
+	    // `String.prototype.match` method
+	    // https://tc39.github.io/ecma262/#sec-string.prototype.match
+	    function match(regexp) {
+	      var O = requireObjectCoercible(this);
+	      var matcher = regexp == undefined ? undefined : regexp[MATCH];
+	      return matcher !== undefined ? matcher.call(regexp, O) : new RegExp(regexp)[MATCH](String(O));
+	    },
+	    // `RegExp.prototype[@@match]` method
+	    // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@match
+	    function (regexp) {
+	      var res = maybeCallNative(nativeMatch, regexp, this);
+	      if (res.done) return res.value;
+
+	      var rx = anObject(regexp);
+	      var S = String(this);
+
+	      if (!rx.global) return regexpExecAbstract(rx, S);
+
+	      var fullUnicode = rx.unicode;
+	      rx.lastIndex = 0;
+	      var A = [];
+	      var n = 0;
+	      var result;
+	      while ((result = regexpExecAbstract(rx, S)) !== null) {
+	        var matchStr = String(result[0]);
+	        A[n] = matchStr;
+	        if (matchStr === '') rx.lastIndex = advanceStringIndex(S, toLength(rx.lastIndex), fullUnicode);
+	        n++;
+	      }
+	      return n === 0 ? null : A;
+	    }
+	  ];
+	});
+
+	var max$1 = Math.max;
+	var min$2 = Math.min;
+	var floor$1 = Math.floor;
+	var SUBSTITUTION_SYMBOLS = /\$([$&'`]|\d\d?|<[^>]*>)/g;
+	var SUBSTITUTION_SYMBOLS_NO_NAMED = /\$([$&'`]|\d\d?)/g;
+
+	var maybeToString = function (it) {
+	  return it === undefined ? it : String(it);
+	};
+
+	// @@replace logic
+	fixRegexpWellKnownSymbolLogic('replace', 2, function (REPLACE, nativeReplace, maybeCallNative, reason) {
+	  return [
+	    // `String.prototype.replace` method
+	    // https://tc39.github.io/ecma262/#sec-string.prototype.replace
+	    function replace(searchValue, replaceValue) {
+	      var O = requireObjectCoercible(this);
+	      var replacer = searchValue == undefined ? undefined : searchValue[REPLACE];
+	      return replacer !== undefined
+	        ? replacer.call(searchValue, O, replaceValue)
+	        : nativeReplace.call(String(O), searchValue, replaceValue);
+	    },
+	    // `RegExp.prototype[@@replace]` method
+	    // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@replace
+	    function (regexp, replaceValue) {
+	      if (reason.REPLACE_KEEPS_$0 || (typeof replaceValue === 'string' && replaceValue.indexOf('$0') === -1)) {
+	        var res = maybeCallNative(nativeReplace, regexp, this, replaceValue);
+	        if (res.done) return res.value;
+	      }
+
+	      var rx = anObject(regexp);
+	      var S = String(this);
+
+	      var functionalReplace = typeof replaceValue === 'function';
+	      if (!functionalReplace) replaceValue = String(replaceValue);
+
+	      var global = rx.global;
+	      if (global) {
+	        var fullUnicode = rx.unicode;
+	        rx.lastIndex = 0;
+	      }
+	      var results = [];
+	      while (true) {
+	        var result = regexpExecAbstract(rx, S);
+	        if (result === null) break;
+
+	        results.push(result);
+	        if (!global) break;
+
+	        var matchStr = String(result[0]);
+	        if (matchStr === '') rx.lastIndex = advanceStringIndex(S, toLength(rx.lastIndex), fullUnicode);
+	      }
+
+	      var accumulatedResult = '';
+	      var nextSourcePosition = 0;
+	      for (var i = 0; i < results.length; i++) {
+	        result = results[i];
+
+	        var matched = String(result[0]);
+	        var position = max$1(min$2(toInteger(result.index), S.length), 0);
+	        var captures = [];
+	        // NOTE: This is equivalent to
+	        //   captures = result.slice(1).map(maybeToString)
+	        // but for some reason `nativeSlice.call(result, 1, result.length)` (called in
+	        // the slice polyfill when slicing native arrays) "doesn't work" in safari 9 and
+	        // causes a crash (https://pastebin.com/N21QzeQA) when trying to debug it.
+	        for (var j = 1; j < result.length; j++) captures.push(maybeToString(result[j]));
+	        var namedCaptures = result.groups;
+	        if (functionalReplace) {
+	          var replacerArgs = [matched].concat(captures, position, S);
+	          if (namedCaptures !== undefined) replacerArgs.push(namedCaptures);
+	          var replacement = String(replaceValue.apply(undefined, replacerArgs));
+	        } else {
+	          replacement = getSubstitution(matched, S, position, captures, namedCaptures, replaceValue);
+	        }
+	        if (position >= nextSourcePosition) {
+	          accumulatedResult += S.slice(nextSourcePosition, position) + replacement;
+	          nextSourcePosition = position + matched.length;
+	        }
+	      }
+	      return accumulatedResult + S.slice(nextSourcePosition);
+	    }
+	  ];
+
+	  // https://tc39.github.io/ecma262/#sec-getsubstitution
+	  function getSubstitution(matched, str, position, captures, namedCaptures, replacement) {
+	    var tailPos = position + matched.length;
+	    var m = captures.length;
+	    var symbols = SUBSTITUTION_SYMBOLS_NO_NAMED;
+	    if (namedCaptures !== undefined) {
+	      namedCaptures = toObject(namedCaptures);
+	      symbols = SUBSTITUTION_SYMBOLS;
+	    }
+	    return nativeReplace.call(replacement, symbols, function (match, ch) {
+	      var capture;
+	      switch (ch.charAt(0)) {
+	        case '$': return '$';
+	        case '&': return matched;
+	        case '`': return str.slice(0, position);
+	        case "'": return str.slice(tailPos);
+	        case '<':
+	          capture = namedCaptures[ch.slice(1, -1)];
+	          break;
+	        default: // \d\d?
+	          var n = +ch;
+	          if (n === 0) return match;
+	          if (n > m) {
+	            var f = floor$1(n / 10);
+	            if (f === 0) return match;
+	            if (f <= m) return captures[f - 1] === undefined ? ch.charAt(1) : captures[f - 1] + ch.charAt(1);
+	            return match;
+	          }
+	          capture = captures[n - 1];
+	      }
+	      return capture === undefined ? '' : capture;
+	    });
+	  }
+	});
+
+	var MATCH = wellKnownSymbol('match');
+
+	// `IsRegExp` abstract operation
+	// https://tc39.github.io/ecma262/#sec-isregexp
+	var isRegexp = function (it) {
+	  var isRegExp;
+	  return isObject(it) && ((isRegExp = it[MATCH]) !== undefined ? !!isRegExp : classofRaw(it) == 'RegExp');
+	};
+
+	var SPECIES$3 = wellKnownSymbol('species');
+
+	// `SpeciesConstructor` abstract operation
+	// https://tc39.github.io/ecma262/#sec-speciesconstructor
+	var speciesConstructor = function (O, defaultConstructor) {
+	  var C = anObject(O).constructor;
+	  var S;
+	  return C === undefined || (S = anObject(C)[SPECIES$3]) == undefined ? defaultConstructor : aFunction$1(S);
+	};
+
+	var arrayPush = [].push;
+	var min$3 = Math.min;
+	var MAX_UINT32 = 0xFFFFFFFF;
+
+	// babel-minify transpiles RegExp('x', 'y') -> /x/y and it causes SyntaxError
+	var SUPPORTS_Y = !fails(function () { return !RegExp(MAX_UINT32, 'y'); });
+
+	// @@split logic
+	fixRegexpWellKnownSymbolLogic('split', 2, function (SPLIT, nativeSplit, maybeCallNative) {
+	  var internalSplit;
+	  if (
+	    'abbc'.split(/(b)*/)[1] == 'c' ||
+	    'test'.split(/(?:)/, -1).length != 4 ||
+	    'ab'.split(/(?:ab)*/).length != 2 ||
+	    '.'.split(/(.?)(.?)/).length != 4 ||
+	    '.'.split(/()()/).length > 1 ||
+	    ''.split(/.?/).length
+	  ) {
+	    // based on es5-shim implementation, need to rework it
+	    internalSplit = function (separator, limit) {
+	      var string = String(requireObjectCoercible(this));
+	      var lim = limit === undefined ? MAX_UINT32 : limit >>> 0;
+	      if (lim === 0) return [];
+	      if (separator === undefined) return [string];
+	      // If `separator` is not a regex, use native split
+	      if (!isRegexp(separator)) {
+	        return nativeSplit.call(string, separator, lim);
+	      }
+	      var output = [];
+	      var flags = (separator.ignoreCase ? 'i' : '') +
+	                  (separator.multiline ? 'm' : '') +
+	                  (separator.unicode ? 'u' : '') +
+	                  (separator.sticky ? 'y' : '');
+	      var lastLastIndex = 0;
+	      // Make `global` and avoid `lastIndex` issues by working with a copy
+	      var separatorCopy = new RegExp(separator.source, flags + 'g');
+	      var match, lastIndex, lastLength;
+	      while (match = regexpExec.call(separatorCopy, string)) {
+	        lastIndex = separatorCopy.lastIndex;
+	        if (lastIndex > lastLastIndex) {
+	          output.push(string.slice(lastLastIndex, match.index));
+	          if (match.length > 1 && match.index < string.length) arrayPush.apply(output, match.slice(1));
+	          lastLength = match[0].length;
+	          lastLastIndex = lastIndex;
+	          if (output.length >= lim) break;
+	        }
+	        if (separatorCopy.lastIndex === match.index) separatorCopy.lastIndex++; // Avoid an infinite loop
+	      }
+	      if (lastLastIndex === string.length) {
+	        if (lastLength || !separatorCopy.test('')) output.push('');
+	      } else output.push(string.slice(lastLastIndex));
+	      return output.length > lim ? output.slice(0, lim) : output;
+	    };
+	  // Chakra, V8
+	  } else if ('0'.split(undefined, 0).length) {
+	    internalSplit = function (separator, limit) {
+	      return separator === undefined && limit === 0 ? [] : nativeSplit.call(this, separator, limit);
+	    };
+	  } else internalSplit = nativeSplit;
+
+	  return [
+	    // `String.prototype.split` method
+	    // https://tc39.github.io/ecma262/#sec-string.prototype.split
+	    function split(separator, limit) {
+	      var O = requireObjectCoercible(this);
+	      var splitter = separator == undefined ? undefined : separator[SPLIT];
+	      return splitter !== undefined
+	        ? splitter.call(separator, O, limit)
+	        : internalSplit.call(String(O), separator, limit);
+	    },
+	    // `RegExp.prototype[@@split]` method
+	    // https://tc39.github.io/ecma262/#sec-regexp.prototype-@@split
+	    //
+	    // NOTE: This cannot be properly polyfilled in engines that don't support
+	    // the 'y' flag.
+	    function (regexp, limit) {
+	      var res = maybeCallNative(internalSplit, regexp, this, limit, internalSplit !== nativeSplit);
+	      if (res.done) return res.value;
+
+	      var rx = anObject(regexp);
+	      var S = String(this);
+	      var C = speciesConstructor(rx, RegExp);
+
+	      var unicodeMatching = rx.unicode;
+	      var flags = (rx.ignoreCase ? 'i' : '') +
+	                  (rx.multiline ? 'm' : '') +
+	                  (rx.unicode ? 'u' : '') +
+	                  (SUPPORTS_Y ? 'y' : 'g');
+
+	      // ^(? + rx + ) is needed, in combination with some S slicing, to
+	      // simulate the 'y' flag.
+	      var splitter = new C(SUPPORTS_Y ? rx : '^(?:' + rx.source + ')', flags);
+	      var lim = limit === undefined ? MAX_UINT32 : limit >>> 0;
+	      if (lim === 0) return [];
+	      if (S.length === 0) return regexpExecAbstract(splitter, S) === null ? [S] : [];
+	      var p = 0;
+	      var q = 0;
+	      var A = [];
+	      while (q < S.length) {
+	        splitter.lastIndex = SUPPORTS_Y ? q : 0;
+	        var z = regexpExecAbstract(splitter, SUPPORTS_Y ? S : S.slice(q));
+	        var e;
+	        if (
+	          z === null ||
+	          (e = min$3(toLength(splitter.lastIndex + (SUPPORTS_Y ? 0 : q)), S.length)) === p
+	        ) {
+	          q = advanceStringIndex(S, q, unicodeMatching);
+	        } else {
+	          A.push(S.slice(p, q));
+	          if (A.length === lim) return A;
+	          for (var i = 1; i <= z.length - 1; i++) {
+	            A.push(z[i]);
+	            if (A.length === lim) return A;
+	          }
+	          q = p = e;
+	        }
+	      }
+	      A.push(S.slice(p));
+	      return A;
+	    }
+	  ];
+	}, !SUPPORTS_Y);
+
+	// a string of all valid unicode whitespaces
+	// eslint-disable-next-line max-len
+	var whitespaces = '\u0009\u000A\u000B\u000C\u000D\u0020\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028\u2029\uFEFF';
+
+	var whitespace = '[' + whitespaces + ']';
+	var ltrim = RegExp('^' + whitespace + whitespace + '*');
+	var rtrim = RegExp(whitespace + whitespace + '*$');
+
+	// `String.prototype.{ trim, trimStart, trimEnd, trimLeft, trimRight }` methods implementation
+	var createMethod$3 = function (TYPE) {
+	  return function ($this) {
+	    var string = String(requireObjectCoercible($this));
+	    if (TYPE & 1) string = string.replace(ltrim, '');
+	    if (TYPE & 2) string = string.replace(rtrim, '');
+	    return string;
+	  };
+	};
+
+	var stringTrim = {
+	  // `String.prototype.{ trimLeft, trimStart }` methods
+	  // https://tc39.github.io/ecma262/#sec-string.prototype.trimstart
+	  start: createMethod$3(1),
+	  // `String.prototype.{ trimRight, trimEnd }` methods
+	  // https://tc39.github.io/ecma262/#sec-string.prototype.trimend
+	  end: createMethod$3(2),
+	  // `String.prototype.trim` method
+	  // https://tc39.github.io/ecma262/#sec-string.prototype.trim
+	  trim: createMethod$3(3)
+	};
+
+	var non = '\u200B\u0085\u180E';
+
+	// check that a method works with the correct list
+	// of whitespaces and has a correct name
+	var forcedStringTrimMethod = function (METHOD_NAME) {
+	  return fails(function () {
+	    return !!whitespaces[METHOD_NAME]() || non[METHOD_NAME]() != non || whitespaces[METHOD_NAME].name !== METHOD_NAME;
+	  });
+	};
+
+	var $trim = stringTrim.trim;
+
+
+	// `String.prototype.trim` method
+	// https://tc39.github.io/ecma262/#sec-string.prototype.trim
+	_export({ target: 'String', proto: true, forced: forcedStringTrimMethod('trim') }, {
+	  trim: function trim() {
+	    return $trim(this);
+	  }
+	});
+
+	// iterable DOM collections
+	// flag - `iterable` interface - 'entries', 'keys', 'values', 'forEach' methods
+	var domIterables = {
+	  CSSRuleList: 0,
+	  CSSStyleDeclaration: 0,
+	  CSSValueList: 0,
+	  ClientRectList: 0,
+	  DOMRectList: 0,
+	  DOMStringList: 0,
+	  DOMTokenList: 1,
+	  DataTransferItemList: 0,
+	  FileList: 0,
+	  HTMLAllCollection: 0,
+	  HTMLCollection: 0,
+	  HTMLFormElement: 0,
+	  HTMLSelectElement: 0,
+	  MediaList: 0,
+	  MimeTypeArray: 0,
+	  NamedNodeMap: 0,
+	  NodeList: 1,
+	  PaintRequestList: 0,
+	  Plugin: 0,
+	  PluginArray: 0,
+	  SVGLengthList: 0,
+	  SVGNumberList: 0,
+	  SVGPathSegList: 0,
+	  SVGPointList: 0,
+	  SVGStringList: 0,
+	  SVGTransformList: 0,
+	  SourceBufferList: 0,
+	  StyleSheetList: 0,
+	  TextTrackCueList: 0,
+	  TextTrackList: 0,
+	  TouchList: 0
+	};
+
+	var $forEach = arrayIteration.forEach;
+
+
+	// `Array.prototype.forEach` method implementation
+	// https://tc39.github.io/ecma262/#sec-array.prototype.foreach
+	var arrayForEach = sloppyArrayMethod('forEach') ? function forEach(callbackfn /* , thisArg */) {
+	  return $forEach(this, callbackfn, arguments.length > 1 ? arguments[1] : undefined);
+	} : [].forEach;
+
+	for (var COLLECTION_NAME in domIterables) {
+	  var Collection = global_1[COLLECTION_NAME];
+	  var CollectionPrototype = Collection && Collection.prototype;
+	  // some Chrome versions have non-configurable methods on DOMTokenList
+	  if (CollectionPrototype && CollectionPrototype.forEach !== arrayForEach) try {
+	    createNonEnumerableProperty(CollectionPrototype, 'forEach', arrayForEach);
+	  } catch (error) {
+	    CollectionPrototype.forEach = arrayForEach;
+	  }
+	}
+
+	function _typeof(obj) {
+	  if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
+	    _typeof = function (obj) {
+	      return typeof obj;
+	    };
+	  } else {
+	    _typeof = function (obj) {
+	      return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
+	    };
+	  }
+
+	  return _typeof(obj);
+	}
+
+	var Utils = $.fn.bootstrapTable.utils;
+	var searchControls = 'select, input:not([type="checkbox"]):not([type="radio"])';
+	function getOptionsFromSelectControl(selectControl) {
+	  return selectControl.get(selectControl.length - 1).options;
+	}
+	function getControlContainer(that) {
+	  if (that.options.filterControlContainer) {
+	    return $("".concat(that.options.filterControlContainer));
+	  }
+
+	  return that.$header;
+	}
+	function getSearchControls(that) {
+	  return getControlContainer(that).find(searchControls);
+	}
+	function hideUnusedSelectOptions(selectControl, uniqueValues) {
+	  var options = getOptionsFromSelectControl(selectControl);
+
+	  for (var i = 0; i < options.length; i++) {
+	    if (options[i].value !== '') {
+	      if (!uniqueValues.hasOwnProperty(options[i].value)) {
+	        selectControl.find(Utils.sprintf('option[value=\'%s\']', options[i].value)).hide();
+	      } else {
+	        selectControl.find(Utils.sprintf('option[value=\'%s\']', options[i].value)).show();
+	      }
+	    }
+	  }
+	}
+	function existOptionInSelectControl(selectControl, value) {
+	  var options = getOptionsFromSelectControl(selectControl);
+
+	  for (var i = 0; i < options.length; i++) {
+	    if (options[i].value === value.toString()) {
+	      // The value is not valid to add
+	      return true;
+	    }
+	  } // If we get here, the value is valid to add
+
+
+	  return false;
+	}
+	function addOptionToSelectControl(selectControl, _value, text, selected) {
+	  var value = _value === undefined || _value === null ? '' : _value.toString().trim();
+	  var $selectControl = $(selectControl.get(selectControl.length - 1));
+
+	  if (!existOptionInSelectControl(selectControl, value)) {
+	    var option = $("<option value=\"".concat(value, "\">").concat(text, "</option>"));
+
+	    if (value === selected) {
+	      option.attr('selected', true);
+	    }
+
+	    $selectControl.append(option);
+	  }
+	}
+	function sortSelectControl(selectControl, orderBy) {
+	  var $selectControl = $(selectControl.get(selectControl.length - 1));
+	  var $opts = $selectControl.find('option:gt(0)');
+
+	  if (orderBy !== 'server') {
+	    $opts.sort(function (a, b) {
+	      return Utils.sort(a.textContent, b.textContent, orderBy === 'desc' ? -1 : 1);
+	    });
+	  }
+
+	  $selectControl.find('option:gt(0)').remove();
+	  $selectControl.append($opts);
+	}
+	function fixHeaderCSS(_ref) {
+	  var $tableHeader = _ref.$tableHeader;
+	  $tableHeader.css('height', '89px');
+	}
+	function getElementClass($element) {
+	  return $element.attr('class').replace('form-control', '').replace('focus-temp', '').replace('search-input', '').trim();
+	}
+	function getCursorPosition(el) {
+	  if (Utils.isIEBrowser()) {
+	    if ($(el).is('input[type=text]')) {
+	      var pos = 0;
+
+	      if ('selectionStart' in el) {
+	        pos = el.selectionStart;
+	      } else if ('selection' in document) {
+	        el.focus();
+	        var Sel = document.selection.createRange();
+	        var SelLength = document.selection.createRange().text.length;
+	        Sel.moveStart('character', -el.value.length);
+	        pos = Sel.text.length - SelLength;
+	      }
+
+	      return pos;
+	    }
+
+	    return -1;
+	  }
+
+	  return -1;
+	}
+	function setCursorPosition(el) {
+	  $(el).val(el.value);
+	}
+	function copyValues(that) {
+	  var searchControls = getSearchControls(that);
+	  that.options.valuesFilterControl = [];
+	  searchControls.each(function () {
+	    var $field = $(this);
+
+	    if (that.options.height) {
+	      var fieldClass = getElementClass($field);
+	      $field = $(".fixed-table-header .".concat(fieldClass));
+	    }
+
+	    that.options.valuesFilterControl.push({
+	      field: $field.closest('[data-field]').data('field'),
+	      value: $field.val(),
+	      position: getCursorPosition($field.get(0)),
+	      hasFocus: $field.is(':focus')
+	    });
+	  });
+	}
+	function setValues(that) {
+	  var field = null;
+	  var result = [];
+	  var searchControls = getSearchControls(that);
+
+	  if (that.options.valuesFilterControl.length > 0) {
+	    //  Callback to apply after settings fields values
+	    var fieldToFocusCallback = null;
+	    searchControls.each(function (index, ele) {
+	      var $this = $(this);
+	      field = $this.closest('[data-field]').data('field');
+	      result = that.options.valuesFilterControl.filter(function (valueObj) {
+	        return valueObj.field === field;
+	      });
+
+	      if (result.length > 0) {
+	        if ($this.is('[type=radio]')) {
+	          return;
+	        }
+
+	        $this.val(result[0].value);
+
+	        if (result[0].hasFocus && result[0].value !== '') {
+	          // set callback if the field had the focus.
+	          fieldToFocusCallback = function (fieldToFocus, carretPosition) {
+	            // Closure here to capture the field and cursor position
+	            var closedCallback = function closedCallback() {
+	              fieldToFocus.focus();
+	              setCursorPosition(fieldToFocus);
+	            };
+
+	            return closedCallback;
+	          }($this.get(0), result[0].position);
+	        }
+	      }
+	    }); // Callback call.
+
+	    if (fieldToFocusCallback !== null) {
+	      fieldToFocusCallback();
+	    }
+	  }
+	}
+	function collectBootstrapCookies() {
+	  var cookies = [];
+	  var foundCookies = document.cookie.match(/(?:bs.table.)(\w*)/g);
+	  var foundLocalStorage = localStorage;
+
+	  if (foundCookies) {
+	    $.each(foundCookies, function (i, _cookie) {
+	      var cookie = _cookie;
+
+	      if (/./.test(cookie)) {
+	        cookie = cookie.split('.').pop();
+	      }
+
+	      if ($.inArray(cookie, cookies) === -1) {
+	        cookies.push(cookie);
+	      }
+	    });
+	  }
+
+	  if (foundLocalStorage) {
+	    for (var i = 0; i < foundLocalStorage.length; i++) {
+	      var cookie = foundLocalStorage.key(i);
+
+	      if (/./.test(cookie)) {
+	        cookie = cookie.split('.').pop();
+	      }
+
+	      if (!cookies.includes(cookie)) {
+	        cookies.push(cookie);
+	      }
+	    }
+	  }
+
+	  return cookies;
+	}
+	function escapeID(id) {
+	  // eslint-disable-next-line no-useless-escape
+	  return String(id).replace(/([:.\[\],])/g, '\\$1');
+	}
+	function isColumnSearchableViaSelect(_ref2) {
+	  var filterControl = _ref2.filterControl,
+	      searchable = _ref2.searchable;
+	  return filterControl && filterControl.toLowerCase() === 'select' && searchable;
+	}
+	function isFilterDataNotGiven(_ref3) {
+	  var filterData = _ref3.filterData;
+	  return filterData === undefined || filterData.toLowerCase() === 'column';
+	}
+	function hasSelectControlElement(selectControl) {
+	  return selectControl && selectControl.length > 0;
+	}
+	function initFilterSelectControls(that) {
+	  var data = that.data;
+	  var z = that.options.pagination ? that.options.sidePagination === 'server' ? that.pageTo : that.options.totalRows : that.pageTo;
+	  $.each(that.header.fields, function (j, field) {
+	    var column = that.columns[that.fieldsColumnsIndex[field]];
+	    var selectControl = getControlContainer(that).find("select.bootstrap-table-filter-control-".concat(escapeID(column.field)));
+
+	    if (isColumnSearchableViaSelect(column) && isFilterDataNotGiven(column) && hasSelectControlElement(selectControl)) {
+	      if (selectControl.get(selectControl.length - 1).options.length === 0) {
+	        // Added the default option
+	        addOptionToSelectControl(selectControl, '', column.filterControlPlaceholder, column.filterDefault);
+	      }
+
+	      var uniqueValues = {};
+
+	      for (var i = 0; i < z; i++) {
+	        // Added a new value
+	        var fieldValue = data[i][field];
+	        var formatter = that.options.editable && column.editable ? column._formatter : that.header.formatters[j];
+	        var formattedValue = Utils.calculateObjectValue(that.header, formatter, [fieldValue, data[i], i], fieldValue);
+
+	        if (column.filterDataCollector) {
+	          formattedValue = Utils.calculateObjectValue(that.header, column.filterDataCollector, [fieldValue, data[i], formattedValue], formattedValue);
+	        }
+
+	        if (column.searchFormatter) {
+	          fieldValue = formattedValue;
+	        }
+
+	        uniqueValues[formattedValue] = fieldValue;
+
+	        if (_typeof(formattedValue) === 'object' && formattedValue !== null) {
+	          formattedValue.forEach(function (value) {
+	            addOptionToSelectControl(selectControl, value, value, column.filterDefault);
+	          });
+	          continue;
+	        }
+
+	        for (var key in uniqueValues) {
+	          addOptionToSelectControl(selectControl, uniqueValues[key], key, column.filterDefault);
+	        }
+	      }
+
+	      sortSelectControl(selectControl, column.filterOrderBy);
+
+	      if (that.options.hideUnusedSelectOptions) {
+	        hideUnusedSelectOptions(selectControl, uniqueValues);
+	      }
+	    }
+	  });
+	}
+	function getFilterDataMethod(objFilterDataMethod, searchTerm) {
+	  var keys = Object.keys(objFilterDataMethod);
+
+	  for (var i = 0; i < keys.length; i++) {
+	    if (keys[i] === searchTerm) {
+	      return objFilterDataMethod[searchTerm];
+	    }
+	  }
+
+	  return null;
+	}
+	function createControls(that, header) {
+	  var addedFilterControl = false;
+	  var html;
+	  $.each(that.columns, function (_, column) {
+	    html = [];
+
+	    if (!column.visible) {
+	      return;
+	    }
+
+	    if (!column.filterControl && !that.options.filterControlContainer) {
+	      html.push('<div class="no-filter-control"></div>');
+	    } else if (that.options.filterControlContainer) {
+	      var $filterControls = $(".bootstrap-table-filter-control-".concat(column.field));
+	      $.each($filterControls, function (_, filterControl) {
+	        var $filterControl = $(filterControl);
+
+	        if (!$filterControl.is('[type=radio]')) {
+	          var placeholder = column.filterControlPlaceholder ? column.filterControlPlaceholder : '';
+	          $filterControl.attr('placeholder', placeholder).val(column.filterDefault);
+	        }
+
+	        $filterControl.attr('data-field', column.field);
+	      });
+	      addedFilterControl = true;
+	    } else {
+	      var nameControl = column.filterControl.toLowerCase();
+	      html.push('<div class="filter-control">');
+	      addedFilterControl = true;
+
+	      if (column.searchable && that.options.filterTemplate[nameControl]) {
+	        html.push(that.options.filterTemplate[nameControl](that, column.field, column.filterControlPlaceholder ? column.filterControlPlaceholder : '', column.filterDefault));
+	      }
+	    }
+
+	    if (!column.filterControl && '' !== column.filterDefault && 'undefined' !== typeof column.filterDefault) {
+	      if ($.isEmptyObject(that.filterColumnsPartial)) {
+	        that.filterColumnsPartial = {};
+	      }
+
+	      that.filterColumnsPartial[column.field] = column.filterDefault;
+	    }
+
+	    $.each(header.find('th'), function (i, th) {
+	      var $th = $(th);
+
+	      if ($th.data('field') === column.field) {
+	        $th.find('.fht-cell').append(html.join(''));
+	        return false;
+	      }
+	    });
+
+	    if (column.filterData && column.filterData.toLowerCase() !== 'column') {
+	      var filterDataType = getFilterDataMethod(
+	      /* eslint-disable no-use-before-define */
+	      filterDataMethods, column.filterData.substring(0, column.filterData.indexOf(':')));
+	      var filterDataSource;
+	      var selectControl;
+
+	      if (filterDataType) {
+	        filterDataSource = column.filterData.substring(column.filterData.indexOf(':') + 1, column.filterData.length);
+	        selectControl = header.find(".bootstrap-table-filter-control-".concat(escapeID(column.field)));
+	        addOptionToSelectControl(selectControl, '', column.filterControlPlaceholder, column.filterDefault);
+	        filterDataType(filterDataSource, selectControl, that.options.filterOrderBy, column.filterDefault);
+	      } else {
+	        throw new SyntaxError('Error. You should use any of these allowed filter data methods: var, obj, json, url, func.' + ' Use like this: var: {key: "value"}');
+	      }
+	    }
+	  });
+
+	  if (addedFilterControl) {
+	    header.off('keyup', 'input').on('keyup', 'input', function (_ref4, obj) {
+	      var currentTarget = _ref4.currentTarget,
+	          keyCode = _ref4.keyCode;
+	      syncControls(that); // Simulate enter key action from clear button
+
+	      keyCode = obj ? obj.keyCode : keyCode;
+
+	      if (that.options.searchOnEnterKey && keyCode !== 13) {
+	        return;
+	      }
+
+	      if ($.inArray(keyCode, [37, 38, 39, 40]) > -1) {
+	        return;
+	      }
+
+	      var $currentTarget = $(currentTarget);
+
+	      if ($currentTarget.is(':checkbox') || $currentTarget.is(':radio')) {
+	        return;
+	      }
+
+	      clearTimeout(currentTarget.timeoutId || 0);
+	      currentTarget.timeoutId = setTimeout(function () {
+	        that.onColumnSearch({
+	          currentTarget: currentTarget,
+	          keyCode: keyCode
+	        });
+	      }, that.options.searchTimeOut);
+	    });
+	    header.off('change', 'select:not(".ms-offscreen")').on('change', 'select:not(".ms-offscreen")', function (_ref5) {
+	      var currentTarget = _ref5.currentTarget,
+	          keyCode = _ref5.keyCode;
+	      syncControls(that);
+	      var $select = $(currentTarget);
+	      var value = $select.val();
+
+	      if (value && value.length > 0 && value.trim()) {
+	        $select.find('option[selected]').removeAttr('selected');
+	        $select.find('option[value="' + value + '"]').attr('selected', true);
+	      } else {
+	        $select.find('option[selected]').removeAttr('selected');
+	      }
+
+	      clearTimeout(currentTarget.timeoutId || 0);
+	      currentTarget.timeoutId = setTimeout(function () {
+	        that.onColumnSearch({
+	          currentTarget: currentTarget,
+	          keyCode: keyCode
+	        });
+	      }, that.options.searchTimeOut);
+	    });
+	    header.off('mouseup', 'input:not([type=radio])').on('mouseup', 'input:not([type=radio])', function (_ref6) {
+	      var currentTarget = _ref6.currentTarget,
+	          keyCode = _ref6.keyCode;
+	      var $input = $(currentTarget);
+	      var oldValue = $input.val();
+
+	      if (oldValue === '') {
+	        return;
+	      }
+
+	      setTimeout(function () {
+	        syncControls(that);
+	        var newValue = $input.val();
+
+	        if (newValue === '') {
+	          clearTimeout(currentTarget.timeoutId || 0);
+	          currentTarget.timeoutId = setTimeout(function () {
+	            that.onColumnSearch({
+	              currentTarget: currentTarget,
+	              keyCode: keyCode
+	            });
+	          }, that.options.searchTimeOut);
+	        }
+	      }, 1);
+	    });
+	    header.off('change', 'input[type=radio]').on('change', 'input[type=radio]', function (_ref7) {
+	      var currentTarget = _ref7.currentTarget,
+	          keyCode = _ref7.keyCode;
+	      clearTimeout(currentTarget.timeoutId || 0);
+	      currentTarget.timeoutId = setTimeout(function () {
+	        syncControls(that);
+	        that.onColumnSearch({
+	          currentTarget: currentTarget,
+	          keyCode: keyCode
+	        });
+	      }, that.options.searchTimeOut);
+	    });
+
+	    if (header.find('.date-filter-control').length > 0) {
+	      $.each(that.columns, function (i, _ref8) {
+	        var filterControl = _ref8.filterControl,
+	            field = _ref8.field,
+	            filterDatepickerOptions = _ref8.filterDatepickerOptions;
+
+	        if (filterControl !== undefined && filterControl.toLowerCase() === 'datepicker') {
+	          header.find(".date-filter-control.bootstrap-table-filter-control-".concat(field)).datepicker(filterDatepickerOptions).on('changeDate', function (_ref9) {
+	            var currentTarget = _ref9.currentTarget,
+	                keyCode = _ref9.keyCode;
+	            clearTimeout(currentTarget.timeoutId || 0);
+	            currentTarget.timeoutId = setTimeout(function () {
+	              syncControls(that);
+	              that.onColumnSearch({
+	                currentTarget: currentTarget,
+	                keyCode: keyCode
+	              });
+	            }, that.options.searchTimeOut);
+	          });
+	        }
+	      });
+	    }
+
+	    if (that.options.sidePagination !== 'server' && !that.options.height) {
+	      that.triggerSearch();
+	    }
+
+	    if (!that.options.filterControlVisible) {
+	      header.find('.filter-control, .no-filter-control').hide();
+	    }
+	  } else {
+	    header.find('.filter-control, .no-filter-control').hide();
+	  }
+
+	  that.trigger('created-controls');
+	}
+	function getDirectionOfSelectOptions(_alignment) {
+	  var alignment = _alignment === undefined ? 'left' : _alignment.toLowerCase();
+
+	  switch (alignment) {
+	    case 'left':
+	      return 'ltr';
+
+	    case 'right':
+	      return 'rtl';
+
+	    case 'auto':
+	      return 'auto';
+
+	    default:
+	      return 'ltr';
+	  }
+	}
+	function syncControls(that) {
+	  if (that.options.height) {
+	    var controlsTableHeader = that.$tableHeader.find(searchControls);
+	    that.$header.find(searchControls).each(function (_, control) {
+	      var $control = $(control);
+	      var controlClass = getElementClass($control);
+	      var foundControl = controlsTableHeader.filter(function (_, ele) {
+	        var eleClass = getElementClass($(ele));
+	        return controlClass === eleClass;
+	      });
+
+	      if (foundControl.length === 0) {
+	        return;
+	      }
+
+	      if ($control.is('select')) {
+	        $control.find('option:selected').removeAttr('selected');
+	        $control.find("option[value='".concat(foundControl.val(), "']")).attr('selected', true);
+	      } else {
+	        $control.val(foundControl.val());
+	      }
+	    });
+	  }
+	}
+	var filterDataMethods = {
+	  func: function func(filterDataSource, selectControl, filterOrderBy, selected) {
+	    var variableValues = window[filterDataSource].apply();
+
+	    for (var key in variableValues) {
+	      addOptionToSelectControl(selectControl, key, variableValues[key], selected);
+	    }
+
+	    sortSelectControl(selectControl, filterOrderBy);
+	  },
+	  obj: function obj(filterDataSource, selectControl, filterOrderBy, selected) {
+	    var objectKeys = filterDataSource.split('.');
+	    var variableName = objectKeys.shift();
+	    var variableValues = window[variableName];
+
+	    if (objectKeys.length > 0) {
+	      objectKeys.forEach(function (key) {
+	        variableValues = variableValues[key];
+	      });
+	    }
+
+	    for (var key in variableValues) {
+	      addOptionToSelectControl(selectControl, key, variableValues[key], selected);
+	    }
+
+	    sortSelectControl(selectControl, filterOrderBy);
+	  },
+	  var: function _var(filterDataSource, selectControl, filterOrderBy, selected) {
+	    var variableValues = window[filterDataSource];
+	    var isArray = Array.isArray(variableValues);
+
+	    for (var key in variableValues) {
+	      if (isArray) {
+	        addOptionToSelectControl(selectControl, variableValues[key], variableValues[key], selected);
+	      } else {
+	        addOptionToSelectControl(selectControl, key, variableValues[key], selected);
+	      }
+	    }
+
+	    sortSelectControl(selectControl, filterOrderBy);
+	  },
+	  url: function url(filterDataSource, selectControl, filterOrderBy, selected) {
+	    $.ajax({
+	      url: filterDataSource,
+	      dataType: 'json',
+	      success: function success(data) {
+	        for (var key in data) {
+	          addOptionToSelectControl(selectControl, key, data[key], selected);
+	        }
+
+	        sortSelectControl(selectControl, filterOrderBy);
+	      }
+	    });
+	  },
+	  json: function json(filterDataSource, selectControl, filterOrderBy, selected) {
+	    var variableValues = JSON.parse(filterDataSource);
+
+	    for (var key in variableValues) {
+	      addOptionToSelectControl(selectControl, key, variableValues[key], selected);
+	    }
+
+	    sortSelectControl(selectControl, filterOrderBy);
+	  }
+	};
+
+	exports.addOptionToSelectControl = addOptionToSelectControl;
+	exports.collectBootstrapCookies = collectBootstrapCookies;
+	exports.copyValues = copyValues;
+	exports.createControls = createControls;
+	exports.escapeID = escapeID;
+	exports.existOptionInSelectControl = existOptionInSelectControl;
+	exports.fixHeaderCSS = fixHeaderCSS;
+	exports.getControlContainer = getControlContainer;
+	exports.getCursorPosition = getCursorPosition;
+	exports.getDirectionOfSelectOptions = getDirectionOfSelectOptions;
+	exports.getElementClass = getElementClass;
+	exports.getFilterDataMethod = getFilterDataMethod;
+	exports.getOptionsFromSelectControl = getOptionsFromSelectControl;
+	exports.getSearchControls = getSearchControls;
+	exports.hasSelectControlElement = hasSelectControlElement;
+	exports.hideUnusedSelectOptions = hideUnusedSelectOptions;
+	exports.initFilterSelectControls = initFilterSelectControls;
+	exports.isColumnSearchableViaSelect = isColumnSearchableViaSelect;
+	exports.isFilterDataNotGiven = isFilterDataNotGiven;
+	exports.setCursorPosition = setCursorPosition;
+	exports.setValues = setValues;
+	exports.sortSelectControl = sortSelectControl;
+	exports.syncControls = syncControls;
+
+	Object.defineProperty(exports, '__esModule', { value: true });
+
+})));
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index db98617614..f9a52810a1 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -39,6 +39,7 @@
 <link rel="stylesheet" href="{% static 'css/select2.css' %}">
 <link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}">
 <link rel="stylesheet" href="{% static 'css/bootstrap-toggle.css' %}">
+<link rel="stylesheet" href="{% static 'css/bootstrap-table-filter-control.css' %}">
 <link rel="stylesheet" href="{% static 'css/inventree.css' %}">
 <link rel="stylesheet" href="{% get_color_theme_css user.get_username %}">
 
@@ -99,6 +100,8 @@ InvenTree
 <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/bootstrap/bootstrap-toggle.js' %}"></script>
+<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-filter-control.js' %}"></script>
+<!-- <script type='text/javascript' src="{% static 'script/bootstrap/filter-control-utils.js' %}"></script> -->
 
 <script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
 <script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index bbdca8f8c4..3001790eac 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -210,6 +210,8 @@ function loadParametricPartTable(table, options={}) {
                 field: header,
                 title: header,
                 sortable: true,
+                filterControl: 'input',
+                clear: 'fa-times icon-red',
             });
         }
     }
@@ -226,6 +228,7 @@ function loadParametricPartTable(table, options={}) {
         columns: columns,
         showColumns: true,
         data: table_data,
+        filterControl: true,
     });
 }
 

From 496232ed6d03af818471a35241780dc768959444 Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Thu, 1 Oct 2020 13:46:56 -0500
Subject: [PATCH 08/77] Added tests for Category parameters methods, some code
 clean-up

---
 InvenTree/part/models.py         |  2 +-
 InvenTree/part/test_category.py  | 28 +++++++++++++++++++++++++++-
 InvenTree/templates/js/part.html | 12 ++++--------
 3 files changed, 32 insertions(+), 10 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 272afacd0e..24c7ac1eb7 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -114,7 +114,7 @@ class PartCategory(InvenTreeTree):
     def prefetch_parts_parameters(self, cascade=True):
         """ Prefectch parts parameters """
 
-        return self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template')
+        return self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template').all()
 
     def get_unique_parameters(self, cascade=True, prefetch=None):
         """ Get all unique parameter names for all parts from this category """
diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py
index 40f0f113a0..7fa38d7dcf 100644
--- a/InvenTree/part/test_category.py
+++ b/InvenTree/part/test_category.py
@@ -1,7 +1,7 @@
 from django.test import TestCase
 from django.core.exceptions import ValidationError
 
-from .models import Part, PartCategory
+from .models import Part, PartCategory, PartParameter, PartParameterTemplate
 
 
 class CategoryTest(TestCase):
@@ -15,6 +15,7 @@ class CategoryTest(TestCase):
         'category',
         'part',
         'location',
+        'params',
     ]
 
     def setUp(self):
@@ -94,6 +95,31 @@ class CategoryTest(TestCase):
 
         self.assertEqual(self.electronics.item_count, self.electronics.partcount())
 
+    def test_parameters(self):
+        """ Test that the Category parameters are correctly fetched """
+
+        # Check number of SQL queries to iterate other parameters
+        with self.assertNumQueries(3):
+            # Prefetch: 3 queries (parts, parameters and parameters_template)
+            fasteners = self.fasteners.prefetch_parts_parameters()
+            # Iterate through all parts and parameters
+            for fastener in fasteners:
+                self.assertIsInstance(fastener, Part)
+                for parameter in fastener.parameters.all():
+                    self.assertIsInstance(parameter, PartParameter)
+                    self.assertIsInstance(parameter.template, PartParameterTemplate)
+
+            # Test number of unique parameters
+            self.assertEqual(len(self.fasteners.get_unique_parameters(prefetch=fasteners)), 1)
+            # Test number of parameters found for each part
+            parts_parameters = self.fasteners.get_parts_parameters(prefetch=fasteners)
+            part_infos = ['pk', 'name', 'description']
+            for part_parameter in parts_parameters:
+                # Remove part informations
+                for item in part_infos:
+                    part_parameter.pop(item)
+                self.assertEqual(len(part_parameter), 1)
+
     def test_invalid_name(self):
         # Test that an illegal character is prohibited in a category name
 
diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html
index 3001790eac..e5fafef070 100644
--- a/InvenTree/templates/js/part.html
+++ b/InvenTree/templates/js/part.html
@@ -164,18 +164,16 @@ function loadSimplePartTable(table, url, options={}) {
 
 
 function loadParametricPartTable(table, options={}) {
-    /* Load parametric part data into specified table.
+    /* Load parametric table for part parameters
      * 
      * Args:
      *  - table: HTML reference to the table
-     *  - table_headers: Table headers/columns
+     *  - table_headers: Unique parameters found in category
      *  - table_data: Parameters data
      */
 
     var table_headers = options.headers
     var table_data = options.data
-/*    console.log(table_headers)
-    console.log(table_data)*/
 
     var columns = [];
 
@@ -211,19 +209,17 @@ function loadParametricPartTable(table, options={}) {
                 title: header,
                 sortable: true,
                 filterControl: 'input',
-                clear: 'fa-times icon-red',
+                /* TODO: Search icons are not displayed */
+                /*clear: 'fa-times icon-red',*/
             });
         }
     }
 
     $(table).inventreeTable({
-/*        url: url,*/
         sortName: 'part',
-/*        method: 'get',*/
         queryParams: table_headers,
         groupBy: false,
         name: options.name || 'parametric',
-/*        original: params,*/
         formatNoMatches: function() { return "{% trans "No parts found" %}"; },
         columns: columns,
         showColumns: true,

From f12f8156bd312cf5d40563f52fdaddeb10bd121a Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Fri, 2 Oct 2020 13:54:23 +1000
Subject: [PATCH 09/77] Fix for "next avilable serial number" string

---
 InvenTree/part/models.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 24c7ac1eb7..1400abd225 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -434,7 +434,7 @@ class Part(MPTTModel):
 
                 return _('Next available serial numbers are') + ' ' + text
             else:
-                text = str(latest)
+                text = str(latest + 1)
 
                 return _('Next available serial number is') + ' ' + text
 

From c7403fd512094a4630848df3e64d62bb2bc3356d Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 3 Oct 2020 16:18:03 +1000
Subject: [PATCH 10/77] Add shell interface

---
 InvenTree/InvenTree/settings.py | 1 +
 InvenTree/InvenTree/urls.py     | 1 +
 requirements.txt                | 1 +
 3 files changed, 3 insertions(+)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 7e398a9c34..f9e3285781 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -153,6 +153,7 @@ INSTALLED_APPS = [
     'markdownx',                    # Markdown editing
     'markdownify',                  # Markdown template rendering
     'django_tex',                   # LaTeX output
+    'django_admin_shell',           # Python shell for the admin interface
 ]
 
 LOGGING = {
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index c433cef382..d718871851 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -117,6 +117,7 @@ urlpatterns = [
     url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
     url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
 
+    url(r'^admin/shell/', include('django_admin_shell.urls')),
     url(r'^admin/', admin.site.urls, name='inventree-admin'),
 
     url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')),
diff --git a/requirements.txt b/requirements.txt
index 8faa7f58a5..6e634328ae 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -25,5 +25,6 @@ django-stdimage==5.1.1          # Advanced ImageField management
 django-tex==1.1.7               # LaTeX PDF export
 django-weasyprint==1.0.1        # HTML PDF export
 django-debug-toolbar==2.2       # Debug / profiling toolbar
+django-admin-shell==0.1.2       # Python shell for the admin interface
 
 inventree                       # Install the latest version of the InvenTree API python library
\ No newline at end of file

From bedda66949e2812faa1270048b0ce8fe940cf75d Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 3 Oct 2020 17:37:20 +1000
Subject: [PATCH 11/77] Add custom admin view for the "Group" model

- Ref: https://github.com/Microdisseny/django-groupadmin-users
- Adds ability to edit users within a particular group from the group admin page!
---
 InvenTree/InvenTree/settings.py |  1 +
 InvenTree/users/admin.py        | 67 ++++++++++++++++++++++++++++++++-
 2 files changed, 66 insertions(+), 2 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index f9e3285781..21b8a0ead1 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -138,6 +138,7 @@ INSTALLED_APPS = [
     'part.apps.PartConfig',
     'report.apps.ReportConfig',
     'stock.apps.StockConfig',
+    'users.apps.UsersConfig',
 
     # Third part add-ons
     'django_filters',               # Extended filter functionality
diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index b12fa73f94..00e3ec7040 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -1,3 +1,66 @@
 # -*- coding: utf-8 -*-
-# from __future__ import unicode_literals
-# from django.contrib import admin
+from __future__ import unicode_literals
+
+from django.utils.translation import ugettext_lazy as _
+
+from django.contrib import admin
+from django import forms
+from django.contrib.auth import get_user_model
+from django.contrib.admin.widgets import FilteredSelectMultiple
+from django.contrib.auth.models import Group
+
+User = get_user_model()
+
+
+class InvenTreeGroupAdminForm(forms.ModelForm):
+
+    class Meta:
+        model = Group
+        exclude = []
+        fields = [
+            'users',
+            'permissions',
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if self.instance.pk:
+            # Populate the users field with the current Group users.
+            self.fields['users'].initial = self.instance.user_set.all()
+
+    # Add the users field.
+    users = forms.ModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        widget=FilteredSelectMultiple('users', False),
+        label=_('Users'),
+    )
+
+    def save_m2m(self):
+        # Add the users to the Group.
+        # Deprecated in Django 1.10: Direct assignment to a reverse foreign key
+        #                            or many-to-many relation
+
+        self.instance.user_set.set(self.cleaned_data['users'])
+
+    def save(self, *args, **kwargs):
+        # Default save
+        instance = super().save()
+        # Save many-to-many data
+        self.save_m2m()
+        return instance
+
+
+class RoleGroupAdmin(admin.ModelAdmin):
+    """
+    Custom admin interface for the Group model
+    """
+
+    form = InvenTreeGroupAdminForm
+
+    filter_horizontal = ['permissions']
+
+
+admin.site.unregister(Group)
+admin.site.register(Group, RoleGroupAdmin)

From 16f1b4c78437bf58772788527a28f774b52024b7 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sat, 3 Oct 2020 23:45:24 +1000
Subject: [PATCH 12/77] Add hook to update group permission roles

(doesn't do anything yet)
---
 InvenTree/users/admin.py  |  39 ++++++++++
 InvenTree/users/apps.py   |  25 ++++++
 InvenTree/users/models.py | 159 ++++++++++++++++++++++++++++++++++++++
 tasks.py                  |   3 +-
 4 files changed, 225 insertions(+), 1 deletion(-)

diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index 00e3ec7040..741f898c81 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -11,6 +11,20 @@ from django.contrib.auth.models import Group
 
 User = get_user_model()
 
+from users.models import RuleSet
+
+
+class RuleSetInline(admin.TabularInline):
+    model = RuleSet
+    can_delete = False
+    verbose_name = 'Ruleset'
+    verbose_plural_name = 'Rulesets'
+    fields = ['name'] + [option for option in RuleSet.RULE_OPTIONS]
+    readonly_fields = ['name']
+    max_num = len(RuleSet.RULESET_CHOICES)
+    min_num = 1
+    extra = 0
+
 
 class InvenTreeGroupAdminForm(forms.ModelForm):
 
@@ -18,6 +32,7 @@ class InvenTreeGroupAdminForm(forms.ModelForm):
         model = Group
         exclude = []
         fields = [
+            'name',
             'users',
             'permissions',
         ]
@@ -35,6 +50,7 @@ class InvenTreeGroupAdminForm(forms.ModelForm):
         required=False,
         widget=FilteredSelectMultiple('users', False),
         label=_('Users'),
+        help_text=_('Select which users are assigned to this group')
     )
 
     def save_m2m(self):
@@ -59,8 +75,31 @@ class RoleGroupAdmin(admin.ModelAdmin):
 
     form = InvenTreeGroupAdminForm
 
+    inlines = [
+        RuleSetInline,
+    ]
+
+    def get_formsets_with_inlines(self, request, obj=None):
+        for inline in self.get_inline_instances(request, obj):
+            # Hide RuleSetInline in the 'Add role' view
+            if not isinstance(inline, RuleSetInline) or obj is not None:
+                yield inline.get_formset(request, obj), inline
+
     filter_horizontal = ['permissions']
 
+     # Save inlines before model
+    # https://stackoverflow.com/a/14860703/12794913
+    def save_model(self, request, obj, form, change):
+        if obj is not None:
+            # Save model immediately only if in 'Add role' view
+            super().save_model(request, obj, form, change)
+        else:
+            pass  # don't actually save the parent instance
+
+    def save_formset(self, request, form, formset, change):
+        formset.save()  # this will save the children
+        form.instance.save()  # form.instance is the parent
+
 
 admin.site.unregister(Group)
 admin.site.register(Group, RoleGroupAdmin)
diff --git a/InvenTree/users/apps.py b/InvenTree/users/apps.py
index 251989770b..b352e54baf 100644
--- a/InvenTree/users/apps.py
+++ b/InvenTree/users/apps.py
@@ -1,8 +1,33 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+from django.db.utils import OperationalError, ProgrammingError
+
 from django.apps import AppConfig
 
 
 class UsersConfig(AppConfig):
     name = 'users'
+
+    def ready(self):
+
+        try:
+            self.assign_permissions()
+        except (OperationalError, ProgrammingError):
+            pass    
+
+    def assign_permissions(self):
+
+        from django.contrib.auth.models import Group
+        from users.models import RuleSet, update_group_roles
+
+        # First, delete any rule_set objects which have become outdated!
+        for rule in RuleSet.objects.all():
+            if rule.name not in RuleSet.RULESET_NAMES:
+                print("need to delete:", rule.name)
+                rule.delete()
+
+        # Update group permission assignments for all groups
+        for group in Group.objects.all():
+
+            update_group_roles(group)
\ No newline at end of file
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 40a96afc6f..fd43e683e0 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -1 +1,160 @@
 # -*- coding: utf-8 -*-
+
+from django.contrib.auth.models import Group
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from django.dispatch import receiver
+from django.db.models.signals import post_save
+
+
+class RuleSet(models.Model):
+    """
+    A RuleSet is somewhat like a superset of the django permission  class,
+    in that in encapsulates a bunch of permissions.
+
+    There are *many* apps models used within InvenTree,
+    so it makes sense to group them into "roles".
+
+    These roles translate (roughly) to the menu options available.
+
+    Each role controls permissions for a number of database tables,
+    which are then handled using the normal django permissions approach.
+    """
+
+    RULESET_CHOICES = [
+        ('general', _('General')),
+        ('admin', _('Admin')),
+        ('part', _('Parts')),
+        ('stock', _('Stock')),
+        ('build', _('Build Orders')),
+        ('supplier', _('Suppliers')),
+        ('purchase_order', _('Purchase Orders')),
+        ('customer', _('Customers')),
+        ('sales_order', _('Sales Orders')),
+    ]
+
+    RULESET_NAMES = [
+        choice[0] for choice in RULESET_CHOICES
+    ]
+
+    RULESET_MODELS = {
+        'general': [
+            'part.partstar',
+        ],
+        'admin': [
+            'auth.group',
+            'auth.user',
+            'auth.permission',
+            'authtoken.token',
+        ],
+        'part': [
+            'part.part',
+            'part.bomitem',
+            'part.partcategory',
+            'part.partattachment',
+            'part.partsellpricebreak',
+            'part.parttesttemplate',
+            'part.partparametertemplate',
+            'part.partparameter',
+        ],
+        'stock': [
+            'stock.stockitem',
+            'stock.stocklocation',
+            'stock.stockitemattachment',
+            'stock.stockitemtracking',
+            'stock.stockitemtestresult',
+        ],
+        'build': [
+            'part.part',
+            'part.partcategory',
+            'part.bomitem',
+            'build.build',
+            'build.builditem',
+            'stock.stockitem',
+            'stock.stocklocation',   
+        ]
+    }
+
+    RULE_OPTIONS = [
+        'can_view',
+        'can_add',
+        'can_change',
+        'can_delete',
+    ]
+
+    class Meta:
+        unique_together = (
+            ('name', 'group'),
+        )
+
+    name = models.CharField(
+        max_length=50,
+        choices=RULESET_CHOICES,
+        blank=False,
+        help_text=_('Permission set')
+    )
+
+    group = models.ForeignKey(
+        Group,
+        related_name='rule_sets',
+        blank=False, null=False,
+        on_delete=models.CASCADE,
+        help_text=_('Group'),
+    )
+
+    can_view = models.BooleanField(verbose_name=_('View'), default=True, help_text=_('Permission to view items'))
+
+    can_add = models.BooleanField(verbose_name=_('Create'), default=False, help_text=_('Permission to add items'))
+
+    can_change = models.BooleanField(verbose_name=_('Update'), default=False, help_text=_('Permissions to edit items'))
+
+    can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items'))
+    
+    def __str__(self):
+        return self.name
+
+    def save(self, *args, **kwargs):
+
+        super().save(*args, **kwargs)
+
+    def get_models(self):
+
+        models = {
+            ''
+        }
+
+def update_group_roles(group):
+    """
+    Update group roles:
+    
+    a) Ensure default roles are assigned to each group.
+    b) Ensure group permissions are correctly updated and assigned
+    """
+
+    # List of permissions which must be added to the group
+    permissions_to_add = []
+
+    # List of permissions which must be removed from the group
+    permissions_to_delete = []
+
+    # Get all the rulesets associated with this group
+    for r in RuleSet.RULESET_CHOICES:
+
+        rulename = r[0]
+
+        try:
+            ruleset = RuleSet.objects.get(group=group, name=rulename)
+        except RuleSet.DoesNotExist:
+            # Create the ruleset with default values (if it does not exist)
+            ruleset = RuleSet.objects.create(group=group, name=rulename)
+
+        # TODO - Update permissions here
+
+    # TODO - Update group permissions
+
+
+@receiver(post_save, sender=Group)
+def create_missing_rule_sets(sender, instance, **kwargs):
+
+    update_group_roles(instance)
\ No newline at end of file
diff --git a/tasks.py b/tasks.py
index 9948b470d1..df386633e9 100644
--- a/tasks.py
+++ b/tasks.py
@@ -22,7 +22,8 @@ def apps():
         'part',
         'report',
         'stock',
-        'InvenTree'
+        'InvenTree',
+        'users',
     ]
 
 def localDir():

From 9e4cc73b1cba090275bdd86962c22aa821087504 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 00:01:18 +1000
Subject: [PATCH 13/77] Add migration files

---
 InvenTree/users/migrations/0001_initial.py | 31 ++++++++++++++++++++++
 InvenTree/users/migrations/__init__.py     |  0
 2 files changed, 31 insertions(+)
 create mode 100644 InvenTree/users/migrations/0001_initial.py
 create mode 100644 InvenTree/users/migrations/__init__.py

diff --git a/InvenTree/users/migrations/0001_initial.py b/InvenTree/users/migrations/0001_initial.py
new file mode 100644
index 0000000000..04071c5a63
--- /dev/null
+++ b/InvenTree/users/migrations/0001_initial.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.0.7 on 2020-10-03 13:44
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('auth', '0011_update_proxy_permissions'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RuleSet',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(choices=[('general', 'General'), ('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('supplier', 'Suppliers'), ('purchase_order', 'Purchase Orders'), ('customer', 'Customers'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50)),
+                ('can_view', models.BooleanField(default=True, help_text='Permission to view items', verbose_name='View')),
+                ('can_add', models.BooleanField(default=False, help_text='Permission to add items', verbose_name='Create')),
+                ('can_change', models.BooleanField(default=False, help_text='Permissions to edit items', verbose_name='Update')),
+                ('can_delete', models.BooleanField(default=False, help_text='Permission to delete items', verbose_name='Delete')),
+                ('group', models.ForeignKey(help_text='Group', on_delete=django.db.models.deletion.CASCADE, related_name='rule_sets', to='auth.Group')),
+            ],
+            options={
+                'unique_together': {('name', 'group')},
+            },
+        ),
+    ]
diff --git a/InvenTree/users/migrations/__init__.py b/InvenTree/users/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2

From 6bc5fe2497c7d17038e17758dcf0cddaa88b2567 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 00:03:10 +1000
Subject: [PATCH 14/77] Tab fix

---
 InvenTree/users/admin.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index 741f898c81..9a6b324bce 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -87,7 +87,7 @@ class RoleGroupAdmin(admin.ModelAdmin):
 
     filter_horizontal = ['permissions']
 
-     # Save inlines before model
+    # Save inlines before model
     # https://stackoverflow.com/a/14860703/12794913
     def save_model(self, request, obj, form, change):
         if obj is not None:

From 2039100d3e7359d31a97ba39274f414ec4ec82f6 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 00:24:48 +1000
Subject: [PATCH 15/77] Add some unit testing

---
 InvenTree/company/apps.py                     |  3 +-
 .../migrations/0019_auto_20200413_0642.py     |  2 -
 InvenTree/part/apps.py                        |  2 +-
 InvenTree/users/models.py                     | 26 ++++++++---
 InvenTree/users/tests.py                      | 46 ++++++++++++++++++-
 5 files changed, 67 insertions(+), 12 deletions(-)

diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py
index 0afb18f616..5f84ce507f 100644
--- a/InvenTree/company/apps.py
+++ b/InvenTree/company/apps.py
@@ -38,4 +38,5 @@ class CompanyConfig(AppConfig):
                             company.image = None
                             company.save()
         except (OperationalError, ProgrammingError):
-            print("Could not generate Company thumbnails")
+            # Getting here probably meant the database was in test mode
+            pass
diff --git a/InvenTree/company/migrations/0019_auto_20200413_0642.py b/InvenTree/company/migrations/0019_auto_20200413_0642.py
index c81dfd795a..c3c2f58ea0 100644
--- a/InvenTree/company/migrations/0019_auto_20200413_0642.py
+++ b/InvenTree/company/migrations/0019_auto_20200413_0642.py
@@ -24,7 +24,6 @@ def reverse_association(apps, schema_editor):
     # Exit if there are no SupplierPart objects
     # This crucial otherwise the unit test suite fails!
     if SupplierPart.objects.count() == 0:
-        print("No SupplierPart objects - skipping")
         return
 
     print("Reversing migration for manufacturer association")
@@ -105,7 +104,6 @@ def associate_manufacturers(apps, schema_editor):
     # Exit if there are no SupplierPart objects
     # This crucial otherwise the unit test suite fails!
     if SupplierPart.objects.count() == 0:
-        print("No SupplierPart objects - skipping")
         return
 
     # Link a 'manufacturer_name' to a 'Company'
diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py
index 2137ec5d89..198e58e337 100644
--- a/InvenTree/part/apps.py
+++ b/InvenTree/part/apps.py
@@ -37,4 +37,4 @@ class PartConfig(AppConfig):
                             part.image = None
                             part.save()
         except (OperationalError, ProgrammingError):
-            print("Could not generate Part thumbnails")
+            pass
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index fd43e683e0..9395f3e1f2 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -28,9 +28,7 @@ class RuleSet(models.Model):
         ('part', _('Parts')),
         ('stock', _('Stock')),
         ('build', _('Build Orders')),
-        ('supplier', _('Suppliers')),
         ('purchase_order', _('Purchase Orders')),
-        ('customer', _('Customers')),
         ('sales_order', _('Sales Orders')),
     ]
 
@@ -73,6 +71,21 @@ class RuleSet(models.Model):
             'build.builditem',
             'stock.stockitem',
             'stock.stocklocation',   
+        ],
+        'purchase_order': [
+            'company.company',
+            'company.supplierpart',
+            'company.supplierpricebreak',
+            'order.purchaseorder',
+            'order.purchaseorderattachment',
+            'order.purchaseorderlineitem',
+        ],
+        'sales_order': [
+            'company.company',
+            'order.salesorder',
+            'order.salesorderattachment',
+            'order.salesorderlineitem',
+            'order.salesorderallocation',
         ]
     }
 
@@ -119,10 +132,11 @@ class RuleSet(models.Model):
         super().save(*args, **kwargs)
 
     def get_models(self):
+        """
+        Return the database tables / models that this ruleset covers.
+        """
 
-        models = {
-            ''
-        }
+        return self.RULESET_MODELS.get(self.name, [])
 
 def update_group_roles(group):
     """
@@ -157,4 +171,4 @@ def update_group_roles(group):
 @receiver(post_save, sender=Group)
 def create_missing_rule_sets(sender, instance, **kwargs):
 
-    update_group_roles(instance)
\ No newline at end of file
+    update_group_roles(instance)
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index 57c7c1fe6b..3687762fba 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -1,4 +1,46 @@
 # -*- coding: utf-8 -*-
-# from __future__ import unicode_literals
+from __future__ import unicode_literals
+
+from django.test import TestCase
+
+from users.models import RuleSet
+
+
+class RuleSetModelTest(TestCase):
+    """
+    Some simplistic tests to ensure the RuleSet model is setup correctly.
+    """
+
+    def test_ruleset_models(self):
+
+        keys = RuleSet.RULESET_MODELS.keys()
+        
+        # Check if there are any rulesets which do not have models defined
+
+        missing = [name for name in RuleSet.RULESET_NAMES if name not in keys]
+
+        if len(missing) > 0:
+            print("The following rulesets do not have models assigned:")
+            for m in missing:
+                print("-", m)
+
+        # Check if models have been defined for a ruleset which is incorrect
+        extra = [name for name in keys if name not in RuleSet.RULESET_NAMES]
+
+        if len(extra) > 0:
+            print("The following rulesets have been improperly added to RULESET_MODELS:")
+            for e in extra:
+                print("-", e)
+
+        # Check that each ruleset has models assigned
+        empty = [key for key in keys if len(RuleSet.RULESET_MODELS[key]) == 0]
+
+        if len(empty) > 0:
+            print("The following rulesets have empty entries in RULESET_MODELS:")
+            for e in empty:
+                print("-", e)
+
+        self.assertEqual(len(missing), 0)
+        self.assertEqual(len(extra), 0)
+        self.assertEqual(len(empty), 0)
 
-# from django.test import TestCase

From 6c2eb959a6a63356bfd38aac65db5f68c2ab52b5 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 00:34:22 +1000
Subject: [PATCH 16/77] More unit testing

---
 InvenTree/users/models.py | 72 +++++++++++++++++++--------------------
 InvenTree/users/tests.py  | 28 +++++++++++++++
 2 files changed, 64 insertions(+), 36 deletions(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 9395f3e1f2..403dc94291 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -38,54 +38,54 @@ class RuleSet(models.Model):
 
     RULESET_MODELS = {
         'general': [
-            'part.partstar',
+            'part_partstar',
         ],
         'admin': [
-            'auth.group',
-            'auth.user',
-            'auth.permission',
-            'authtoken.token',
+            'auth_group',
+            'auth_user',
+            'auth_permission',
+            'authtoken_token',
         ],
         'part': [
-            'part.part',
-            'part.bomitem',
-            'part.partcategory',
-            'part.partattachment',
-            'part.partsellpricebreak',
-            'part.parttesttemplate',
-            'part.partparametertemplate',
-            'part.partparameter',
+            'part_part',
+            'part_bomitem',
+            'part_partcategory',
+            'part_partattachment',
+            'part_partsellpricebreak',
+            'part_parttesttemplate',
+            'part_partparametertemplate',
+            'part_partparameter',
         ],
         'stock': [
-            'stock.stockitem',
-            'stock.stocklocation',
-            'stock.stockitemattachment',
-            'stock.stockitemtracking',
-            'stock.stockitemtestresult',
+            'stock_stockitem',
+            'stock_stocklocation',
+            'stock_stockitemattachment',
+            'stock_stockitemtracking',
+            'stock_stockitemtestresult',
         ],
         'build': [
-            'part.part',
-            'part.partcategory',
-            'part.bomitem',
-            'build.build',
-            'build.builditem',
-            'stock.stockitem',
-            'stock.stocklocation',   
+            'part_part',
+            'part_partcategory',
+            'part_bomitem',
+            'build_build',
+            'build_builditem',
+            'stock_stockitem',
+            'stock_stocklocation',   
         ],
         'purchase_order': [
-            'company.company',
-            'company.supplierpart',
-            'company.supplierpricebreak',
-            'order.purchaseorder',
-            'order.purchaseorderattachment',
-            'order.purchaseorderlineitem',
+            'company_company',
+            'part_supplierpart',
+            'part_supplierpricebreak',
+            'order_purchaseorder',
+            'order_purchaseorderattachment',
+            'order_purchaseorderlineitem',
         ],
         'sales_order': [
-            'company.company',
-            'order.salesorder',
-            'order.salesorderattachment',
-            'order.salesorderlineitem',
-            'order.salesorderallocation',
+            'company_company',
+            'order_salesorder',
+            'order_salesorderattachment',
+            'order_salesorderlineitem',
+            'order_salesorderallocation',
         ]
     }
 
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index 3687762fba..6c088441c9 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -2,6 +2,7 @@
 from __future__ import unicode_literals
 
 from django.test import TestCase
+from django.apps import apps
 
 from users.models import RuleSet
 
@@ -44,3 +45,30 @@ class RuleSetModelTest(TestCase):
         self.assertEqual(len(extra), 0)
         self.assertEqual(len(empty), 0)
 
+    def test_model_names(self):
+        """
+        Test that each model defined in the rulesets is valid,
+        based on the database schema!
+        """
+
+        available_models = apps.get_models()
+
+        available_tables = []
+
+        for model in available_models:
+            table_name = model.objects.model._meta.db_table
+            available_tables.append(table_name)
+
+        errors = 0
+
+        # Now check that each defined model is a valid table name
+        for key in RuleSet.RULESET_MODELS.keys():
+
+            models = RuleSet.RULESET_MODELS[key]
+
+            for m in models:
+                if m not in available_tables:
+                    print("{n} is not a valid database table".format(n=m))
+                    errors += 1
+
+        self.assertEqual(errors, 0)
\ No newline at end of file

From 1ded9e1fc0627dae362a14d70aae7b76aba31370 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 00:38:53 +1000
Subject: [PATCH 17/77] Add a warning showing which databases tables are not
 covered by defined rulesets

---
 InvenTree/users/models.py |  1 +
 InvenTree/users/tests.py  | 18 +++++++++++++++++-
 2 files changed, 18 insertions(+), 1 deletion(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 403dc94291..c419f7a25a 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -45,6 +45,7 @@ class RuleSet(models.Model):
             'auth_user',
             'auth_permission',
             'authtoken_token',
+            'users_ruleset',
         ],
         'part': [
             'part_part',
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index 6c088441c9..485c02542a 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -61,14 +61,30 @@ class RuleSetModelTest(TestCase):
 
         errors = 0
 
+        assigned_models = []
+
         # Now check that each defined model is a valid table name
         for key in RuleSet.RULESET_MODELS.keys():
 
             models = RuleSet.RULESET_MODELS[key]
 
             for m in models:
+
+                assigned_models.append(m)
+
                 if m not in available_tables:
                     print("{n} is not a valid database table".format(n=m))
                     errors += 1
 
-        self.assertEqual(errors, 0)
\ No newline at end of file
+        self.assertEqual(errors, 0)
+
+        missing_models = []
+
+        for model in available_tables:
+            if model not in assigned_models:
+                missing_models.append(model)
+
+        if len(missing_models) > 0:
+            print("WARNING: The following database models are not covered by the define RuleSet permissions:")
+            for m in missing_models:
+                print("-", m)
\ No newline at end of file

From c09b4980adc85981e06267ab5b70dde164459a80 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 00:43:02 +1000
Subject: [PATCH 18/77] PEP fixes

---
 InvenTree/users/admin.py  | 4 ++--
 InvenTree/users/apps.py   | 4 ++--
 InvenTree/users/models.py | 7 ++++---
 InvenTree/users/tests.py  | 2 +-
 4 files changed, 9 insertions(+), 8 deletions(-)

diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index 9a6b324bce..a556e60080 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -9,10 +9,10 @@ from django.contrib.auth import get_user_model
 from django.contrib.admin.widgets import FilteredSelectMultiple
 from django.contrib.auth.models import Group
 
-User = get_user_model()
-
 from users.models import RuleSet
 
+User = get_user_model()
+
 
 class RuleSetInline(admin.TabularInline):
     model = RuleSet
diff --git a/InvenTree/users/apps.py b/InvenTree/users/apps.py
index b352e54baf..07e303c1be 100644
--- a/InvenTree/users/apps.py
+++ b/InvenTree/users/apps.py
@@ -14,7 +14,7 @@ class UsersConfig(AppConfig):
         try:
             self.assign_permissions()
         except (OperationalError, ProgrammingError):
-            pass    
+            pass
 
     def assign_permissions(self):
 
@@ -30,4 +30,4 @@ class UsersConfig(AppConfig):
         # Update group permission assignments for all groups
         for group in Group.objects.all():
 
-            update_group_roles(group)
\ No newline at end of file
+            update_group_roles(group)
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index c419f7a25a..a0ae03d803 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -71,7 +71,7 @@ class RuleSet(models.Model):
             'build_build',
             'build_builditem',
             'stock_stockitem',
-            'stock_stocklocation',   
+            'stock_stocklocation',
         ],
         'purchase_order': [
             'company_company',
@@ -139,6 +139,7 @@ class RuleSet(models.Model):
 
         return self.RULESET_MODELS.get(self.name, [])
 
+
 def update_group_roles(group):
     """
     Update group roles:
@@ -148,10 +149,10 @@ def update_group_roles(group):
     """
 
     # List of permissions which must be added to the group
-    permissions_to_add = []
+    # permissions_to_add = []
 
     # List of permissions which must be removed from the group
-    permissions_to_delete = []
+    # permissions_to_delete = []
 
     # Get all the rulesets associated with this group
     for r in RuleSet.RULESET_CHOICES:
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index 485c02542a..b88867a271 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -87,4 +87,4 @@ class RuleSetModelTest(TestCase):
         if len(missing_models) > 0:
             print("WARNING: The following database models are not covered by the define RuleSet permissions:")
             for m in missing_models:
-                print("-", m)
\ No newline at end of file
+                print("-", m)

From d5c0c12d78528b165ee0d0a3d2cc28e02a7ac9e3 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 11:03:14 +1100
Subject: [PATCH 19/77] Add some more unit testing

- ALL models must be covered by rulesets
- Added a RULESET_IGNORE list for models we do not want permissions for
---
 InvenTree/users/models.py | 88 ++++++++++++++++++++++++++++++++++++++-
 InvenTree/users/tests.py  |  8 ++--
 2 files changed, 91 insertions(+), 5 deletions(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index a0ae03d803..8dd634acdf 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -90,6 +90,23 @@ class RuleSet(models.Model):
         ]
     }
 
+    # Database models we ignore permission sets for
+    RULESET_IGNORE = [
+        # Core django models (not user configurable)
+        'django_admin_log',
+        'django_content_type',
+        'django_session',
+
+        # Models which currently do not require permissions
+        'common_inventreesetting',
+        'common_currency',
+        'common_colortheme',
+        'company_contact',
+        'label_stockitemlabel',
+        'report_testreport',
+        'report_reportasset',
+    ]
+
     RULE_OPTIONS = [
         'can_view',
         'can_add',
@@ -125,6 +142,21 @@ class RuleSet(models.Model):
 
     can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items'))
     
+    @staticmethod
+    def get_model_permission_string(model, permission):
+        """
+        Construct the correctly formatted permission string,
+        given the app_model name, and the permission type.
+        """
+
+        app, model = model.split('_')
+
+        return "{app}.{perm}_{model}".format(
+            app=app,
+            perm=permission,
+            model=model
+        )
+
     def __str__(self):
         return self.name
 
@@ -148,11 +180,43 @@ def update_group_roles(group):
     b) Ensure group permissions are correctly updated and assigned
     """
 
+    # List of permissions already associated with this group
+    group_permissions = '??????'
+
     # List of permissions which must be added to the group
-    # permissions_to_add = []
+    permissions_to_add = set()
 
     # List of permissions which must be removed from the group
-    # permissions_to_delete = []
+    permissions_to_delete = set()
+
+    def add_model(name, action, allowed):
+        """
+        Add a new model to the pile:
+
+        args:
+            name - The name of the model e.g. part_part
+            action - The permission action e.g. view
+            allowed - Whether or not the action is allowed
+        """
+
+        if not action in ['view', 'add', 'change', 'delete']:
+            raise ValueError("Action {a} is invalid".format(a=action))
+
+        permission_string = RuleSet.get_model_permission_string(model, action)
+
+        if allowed:
+
+            # An 'allowed' action is always preferenced over a 'forbidden' action
+            if permission_string in permissions_to_delete:
+                permissions_to_delete.remove(permission_string)
+
+            permissions_to_add.add(permission_string)
+
+        else:
+
+            # A forbidden action will be ignored if we have already allowed it
+            if permission_string not in permissions_to_add:
+                permissions_to_delete.add(permission_string)
 
     # Get all the rulesets associated with this group
     for r in RuleSet.RULESET_CHOICES:
@@ -165,12 +229,32 @@ def update_group_roles(group):
             # Create the ruleset with default values (if it does not exist)
             ruleset = RuleSet.objects.create(group=group, name=rulename)
 
+        # Which database tables does this RuleSet touch?
+        models = ruleset.get_models()
+
+        for model in models:
+            # Keep track of the available permissions for each model
+
+            add_model(model, 'view', ruleset.can_view)
+            add_model(model, 'add', ruleset.can_add)
+            add_model(model, 'change', ruleset.can_change)
+            add_model(model, 'delete', ruleset.can_delete)
+
         # TODO - Update permissions here
 
     # TODO - Update group permissions
 
+    print("To add:", permissions_to_add)
+    print("To delete:", permissions_to_delete)
+
 
 @receiver(post_save, sender=Group)
 def create_missing_rule_sets(sender, instance, **kwargs):
+    """
+    Called *after* a Group object is saved.
+    As the linked RuleSet instances are saved *before* the Group,
+    then we can now use these RuleSet values to update the 
+    group permissions.
+    """
 
     update_group_roles(instance)
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index b88867a271..8db631ab98 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -76,15 +76,17 @@ class RuleSetModelTest(TestCase):
                     print("{n} is not a valid database table".format(n=m))
                     errors += 1
 
-        self.assertEqual(errors, 0)
 
         missing_models = []
 
         for model in available_tables:
-            if model not in assigned_models:
+            if model not in assigned_models and model not in RuleSet.RULESET_IGNORE:
                 missing_models.append(model)
 
         if len(missing_models) > 0:
-            print("WARNING: The following database models are not covered by the define RuleSet permissions:")
+            print("The following database models are not covered by the defined RuleSet permissions:")
             for m in missing_models:
                 print("-", m)
+
+        self.assertEqual(errors, 0)
+        self.assertEqual(len(missing_models), 0)
\ No newline at end of file

From c19c014f55e3b0db29ea03a7c32bb4f610178fe0 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 12:18:31 +1100
Subject: [PATCH 20/77] Add or remove permissions from groups as defined by the
 RuleSet links

- Only runs when the group is changed
- Does not add permissions if they already exist
- Does not remove permissions if they do not exist
---
 InvenTree/users/models.py | 87 ++++++++++++++++++++++++++++++++-------
 InvenTree/users/tests.py  |  3 +-
 2 files changed, 73 insertions(+), 17 deletions(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 8dd634acdf..1bffbf86a9 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 
-from django.contrib.auth.models import Group
+from django.contrib.auth.models import Group, Permission
+from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
@@ -172,16 +173,35 @@ class RuleSet(models.Model):
         return self.RULESET_MODELS.get(self.name, [])
 
 
-def update_group_roles(group):
+def update_group_roles(group, debug=False):
     """
-    Update group roles:
-    
-    a) Ensure default roles are assigned to each group.
-    b) Ensure group permissions are correctly updated and assigned
+
+    Iterates through all of the RuleSets associated with the group,
+    and ensures that the correct permissions are either applied or removed from the group.
+
+    This function is called under the following conditions:
+
+    a) Whenever the InvenTree database is launched
+    b) Whenver the group object is updated
+
+    The RuleSet model has complete control over the permissions applied to any group.
+
     """
 
     # List of permissions already associated with this group
-    group_permissions = '??????'
+    group_permissions = set()
+
+    # Iterate through each permission already assigned to this group,
+    # and create a simplified permission key string
+    for p in group.permissions.all():
+        (permission, app, model) = p.natural_key()
+
+        permission_string = '{app}.{perm}'.format(
+            app=app,
+            perm=permission
+        )
+
+        group_permissions.add(permission_string)
 
     # List of permissions which must be added to the group
     permissions_to_add = set()
@@ -199,7 +219,7 @@ def update_group_roles(group):
             allowed - Whether or not the action is allowed
         """
 
-        if not action in ['view', 'add', 'change', 'delete']:
+        if action not in ['view', 'add', 'change', 'delete']:
             raise ValueError("Action {a} is invalid".format(a=action))
 
         permission_string = RuleSet.get_model_permission_string(model, action)
@@ -210,13 +230,16 @@ def update_group_roles(group):
             if permission_string in permissions_to_delete:
                 permissions_to_delete.remove(permission_string)
 
-            permissions_to_add.add(permission_string)
+            if permission_string not in group_permissions:
+                permissions_to_add.add(permission_string)
 
         else:
 
             # A forbidden action will be ignored if we have already allowed it
             if permission_string not in permissions_to_add:
-                permissions_to_delete.add(permission_string)
+
+                if permission_string in group_permissions:
+                    permissions_to_delete.add(permission_string)
 
     # Get all the rulesets associated with this group
     for r in RuleSet.RULESET_CHOICES:
@@ -240,12 +263,46 @@ def update_group_roles(group):
             add_model(model, 'change', ruleset.can_change)
             add_model(model, 'delete', ruleset.can_delete)
 
-        # TODO - Update permissions here
+    def get_permission_object(permission_string):
+        """
+        Find the permission object in the database,
+        from the simplified permission string
 
-    # TODO - Update group permissions
+        Args:
+            permission_string - a simplified permission_string e.g. 'part.view_partcategory'
 
-    print("To add:", permissions_to_add)
-    print("To delete:", permissions_to_delete)
+        Returns the permission object in the database associated with the permission string
+        """
+
+        (app, perm) = permission_string.split('.')
+
+        (permission_name, model) = perm.split('_')
+
+        content_type = ContentType.objects.get(app_label=app, model=model)
+
+        permission = Permission.objects.get(content_type=content_type, codename=perm)
+
+        return permission
+
+    # Add any required permissions to the group
+    for perm in permissions_to_add:
+        
+        permission = get_permission_object(perm)
+
+        group.permissions.add(permission)
+
+        if debug:
+            print(f"Adding permission {perm} to group {group.name}")
+
+    # Remove any extra permissions from the group
+    for perm in permissions_to_delete:
+
+        permission = get_permission_object(perm)
+
+        group.permissions.remove(permission)
+
+        if debug:
+            print(f"Removing permission {perm} from group {group.name}")
 
 
 @receiver(post_save, sender=Group)
@@ -253,7 +310,7 @@ def create_missing_rule_sets(sender, instance, **kwargs):
     """
     Called *after* a Group object is saved.
     As the linked RuleSet instances are saved *before* the Group,
-    then we can now use these RuleSet values to update the 
+    then we can now use these RuleSet values to update the
     group permissions.
     """
 
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index 8db631ab98..ad3a6ec5ae 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -76,7 +76,6 @@ class RuleSetModelTest(TestCase):
                     print("{n} is not a valid database table".format(n=m))
                     errors += 1
 
-
         missing_models = []
 
         for model in available_tables:
@@ -89,4 +88,4 @@ class RuleSetModelTest(TestCase):
                 print("-", m)
 
         self.assertEqual(errors, 0)
-        self.assertEqual(len(missing_models), 0)
\ No newline at end of file
+        self.assertEqual(len(missing_models), 0)

From cda52a58e318c7f0aeed0cc1c50b2617418f9d5d Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 12:19:56 +1100
Subject: [PATCH 21/77] Remove manual 'permissions' control from groups admin
 interface

- Does not actually *do* anything any more as the RuleSet approach overrides it anyway
---
 InvenTree/users/admin.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index a556e60080..c9546e0335 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -34,7 +34,6 @@ class InvenTreeGroupAdminForm(forms.ModelForm):
         fields = [
             'name',
             'users',
-            'permissions',
         ]
 
     def __init__(self, *args, **kwargs):

From 31b699d5216bb0df1ff64b813db78eecd14bc53b Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 12:47:19 +1100
Subject: [PATCH 22/77] Hide "user permissions" view from the admin interface

---
 InvenTree/users/admin.py | 35 +++++++++++++++++++++++++++++++++--
 1 file changed, 33 insertions(+), 2 deletions(-)

diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index c9546e0335..86d4bb1a86 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -8,6 +8,7 @@ from django import forms
 from django.contrib.auth import get_user_model
 from django.contrib.admin.widgets import FilteredSelectMultiple
 from django.contrib.auth.models import Group
+from django.contrib.auth.admin import UserAdmin
 
 from users.models import RuleSet
 
@@ -15,6 +16,10 @@ User = get_user_model()
 
 
 class RuleSetInline(admin.TabularInline):
+    """
+    Class for displaying inline RuleSet data in the Group admin page.
+    """
+
     model = RuleSet
     can_delete = False
     verbose_name = 'Ruleset'
@@ -27,6 +32,11 @@ class RuleSetInline(admin.TabularInline):
 
 
 class InvenTreeGroupAdminForm(forms.ModelForm):
+    """
+    Custom admin form for the Group model.
+
+    Adds the ability for editing user membership directly in the group admin page.
+    """
 
     class Meta:
         model = Group
@@ -54,8 +64,6 @@ class InvenTreeGroupAdminForm(forms.ModelForm):
 
     def save_m2m(self):
         # Add the users to the Group.
-        # Deprecated in Django 1.10: Direct assignment to a reverse foreign key
-        #                            or many-to-many relation
 
         self.instance.user_set.set(self.cleaned_data['users'])
 
@@ -100,5 +108,28 @@ class RoleGroupAdmin(admin.ModelAdmin):
         form.instance.save()  # form.instance is the parent
 
 
+class InvenTreeUserAdmin(UserAdmin):
+    """
+    Custom admin page for the User model.
+
+    Hides the "permissions" view as this is now handled
+    entirely by groups and RuleSets.
+
+    (And it's confusing!)
+    """
+
+    fieldsets = (
+        (None, {'fields': ('username', 'password')}),
+        (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
+        (_('Permissions'), {
+            'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'),
+        }),
+        (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
+    )
+
+
 admin.site.unregister(Group)
 admin.site.register(Group, RoleGroupAdmin)
+
+admin.site.unregister(User)
+admin.site.register(User, InvenTreeUserAdmin)

From 929411e49a96beea57b33cf1312d92a31b3829ea Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 12:53:24 +1100
Subject: [PATCH 23/77] Remove "general" ruleset

---
 InvenTree/users/models.py | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 1bffbf86a9..8f349e2f18 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -24,7 +24,6 @@ class RuleSet(models.Model):
     """
 
     RULESET_CHOICES = [
-        ('general', _('General')),
         ('admin', _('Admin')),
         ('part', _('Parts')),
         ('stock', _('Stock')),
@@ -38,9 +37,6 @@ class RuleSet(models.Model):
     ]
 
     RULESET_MODELS = {
-        'general': [
-            'part_partstar',
-        ],
         'admin': [
             'auth_group',
             'auth_user',
@@ -99,13 +95,14 @@ class RuleSet(models.Model):
         'django_session',
 
         # Models which currently do not require permissions
-        'common_inventreesetting',
-        'common_currency',
         'common_colortheme',
+        'common_currency',
+        'common_inventreesetting',
         'company_contact',
         'label_stockitemlabel',
-        'report_testreport',
         'report_reportasset',
+        'report_testreport',
+        'part_partstar',
     ]
 
     RULE_OPTIONS = [

From fb09f53dc9a2016cd472ac5c63a6202b930ae616 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 12:58:45 +1100
Subject: [PATCH 24/77] Add missing migration file

---
 .../migrations/0002_auto_20201004_0158.py      | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)
 create mode 100644 InvenTree/users/migrations/0002_auto_20201004_0158.py

diff --git a/InvenTree/users/migrations/0002_auto_20201004_0158.py b/InvenTree/users/migrations/0002_auto_20201004_0158.py
new file mode 100644
index 0000000000..a0573a89be
--- /dev/null
+++ b/InvenTree/users/migrations/0002_auto_20201004_0158.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.7 on 2020-10-04 01:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='ruleset',
+            name='name',
+            field=models.CharField(choices=[('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50),
+        ),
+    ]

From b27f9263103dcf958222e43f81a4d5bad7e20b6f Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 13:51:52 +1100
Subject: [PATCH 25/77] Add ability to filter BOM API by "trackable" status of
 the sub_part object

---
 InvenTree/part/api.py | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index c70ef5c21a..9a86bb98d5 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -777,6 +777,13 @@ class BomList(generics.ListCreateAPIView):
         if sub_part is not None:
             queryset = queryset.filter(sub_part=sub_part)
 
+        # Filter by "trackable" status of the sub-part
+        trackable = self.request.query_params.get('trackable', None)
+
+        if trackable is not None:
+            trackable = str2bool(trackable)
+            queryset = queryset.filter(sub_part__trackable=trackable)
+
         return queryset
 
     permission_classes = [

From b467c8a1ef3e53801601711e3da9513e0af1b96a Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 15:17:46 +1100
Subject: [PATCH 26/77] Add front-end functions to render an "installed stock"
 table

---
 .../static/script/inventree/tables.js         |  16 ++-
 InvenTree/templates/js/stock.html             | 110 ++++++++++++++++++
 2 files changed, 123 insertions(+), 3 deletions(-)

diff --git a/InvenTree/InvenTree/static/script/inventree/tables.js b/InvenTree/InvenTree/static/script/inventree/tables.js
index cc4320307b..6d57240979 100644
--- a/InvenTree/InvenTree/static/script/inventree/tables.js
+++ b/InvenTree/InvenTree/static/script/inventree/tables.js
@@ -109,10 +109,20 @@ $.fn.inventreeTable = function(options) {
     options.pagination = true;
     options.pageSize = inventreeLoad(varName, 25);
     options.pageList = [25, 50, 100, 250, 'all'];
+
     options.rememberOrder = true;
-    options.sortable = true;
-    options.search = true;
-    options.showColumns = true;
+
+    if (options.sortable == null) {
+        options.sortable = true;
+    }
+
+    if (options.search == null) {
+        options.search = true;
+    }
+
+    if (options.showColumns == null) {
+        options.showColumns = true;
+    }
 
     // Callback to save pagination data
     options.onPageChange = function(number, size) {
diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html
index dd99c5b611..4ec2507b31 100644
--- a/InvenTree/templates/js/stock.html
+++ b/InvenTree/templates/js/stock.html
@@ -798,4 +798,114 @@ function createNewStockItem(options) {
     ];
 
     launchModalForm("{% url 'stock-item-create' %}", options);
+}
+
+
+function loadInstalledInTable(table, options) {
+    /*
+    * Display a table showing the stock items which are installed in this stock item.
+    * This is a multi-level tree table, where the "top level" items are Part objects,
+    * and the children of each top-level item are the associated installed stock items.
+    * 
+    * The process for retrieving data and displaying the table is as follows:
+    *
+    * A) Get BOM data for the stock item
+    *    - It is assumed that the stock item will be for an assembly
+    *      (otherwise why are we installing stuff anyway?)
+    *    - Request BOM items for stock_item.part (and only for trackable sub items)
+    * 
+    * B) Add parts to table
+    *    - Create rows for each trackable sub-part in the table
+    *
+    * C) Gather installed stock item data
+    *    - Get the list of installed stock items via the API
+    *    - If the Part reference is already in the table, add the sub-item as a child
+    *    - If this is a stock item for a *new* part, request that part from the API,
+    *      and add that part as a new row, then add the stock item as a child of that part
+    *
+    * D) Enjoy!
+    *
+    *
+    * And the options object contains the following things:
+    *
+    * - stock_item: The PK of the master stock_item object
+    * - part: The PK of the Part reference of the stock_item object
+    */
+
+    table.inventreeTable(
+        {
+            url: "{% url 'api-bom-list' %}",
+            queryParams: {
+                part: options.part,
+                trackable: true,
+                sub_part_detail: true,
+            },
+            showColumns: false,
+            name: 'installed-in',
+            columns: [
+                {
+                    checkbox: true,
+                    title: '{% trans 'Select' %}',
+                    searchable: false,
+                    switchable: false,
+                },
+                {
+                    field: 'pk',
+                    title: 'ID',
+                    visible: false,
+                    switchable: false,
+                },
+                {
+                    field: 'part',
+                    title: '{% trans "Part" %}',
+                    sortable: true,
+                    formatter: function(value, row, index, field) {
+        
+                        var url = `/stock/item/${row.pk}/`;
+                        var thumb = row.sub_part_detail.thumbnail;
+                        var name = row.sub_part_detail.full_name;
+        
+                        html = imageHoverIcon(thumb) + renderLink(name, url);
+                        
+                        return html;
+                    }
+                },
+                {
+                    field: 'installed',
+                    title: '{% trans "Installed" %}',
+                    sortable: false,
+                    formatter: function(value, row, index, field) {
+                        // Construct a progress showing how many items have been installed
+
+                        var installed = row.installed || 0;
+                        var required = row.quantity || 0;
+
+                        var progress = makeProgressBar(installed, required, {
+                            id: row.sub_part.pk,
+                        });
+
+                        return progress;
+                    }
+                },
+                {
+                    field: 'actions',
+                    switchable: false,
+                    formatter: function(value, row) {
+                        var pk = row.sub_part.pk;
+
+                        var html = `<div class='btn-group float-right' role='group'>`;
+
+                        html += makeIconButton('fa-link', 'button-install', pk, '{% trans "Install item" %}');
+
+                        html += `</div>`;
+
+                        return html;
+                    }
+                }
+            ],
+            onLoadSuccess: function() {
+                console.log('data loaded!');
+            }
+        }
+    );
 }
\ No newline at end of file

From f04977e7e12dd7f6ba17abf6bbab5a017b1c0ab8 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 20:41:28 +1100
Subject: [PATCH 27/77] Add form / view for installing a stock item into
 another stock item

---
 InvenTree/stock/forms.py | 28 ++++++++++++++++++++++++++++
 InvenTree/stock/urls.py  |  1 +
 InvenTree/stock/views.py | 40 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 69 insertions(+)

diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index a5c689a605..ebcf31cb23 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -8,6 +8,7 @@ from __future__ import unicode_literals
 from django import forms
 from django.forms.utils import ErrorDict
 from django.utils.translation import ugettext as _
+from django.core.validators import MinValueValidator
 
 from mptt.fields import TreeNodeChoiceField
 
@@ -271,6 +272,33 @@ class ExportOptionsForm(HelperForm):
         self.fields['file_format'].choices = self.get_format_choices()
 
 
+class InstallStockForm(HelperForm):
+    """
+    Form for manually installing a stock item into another stock item
+    """
+
+    stock_item = forms.ModelChoiceField(
+        required=True,
+        queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER),
+        help_text=_('Stock item to install')
+    )
+
+    quantity = RoundingDecimalFormField(
+        max_digits=10, decimal_places=5,
+        help_text=_('Stock quantity to assign'),
+        validators=[
+            MinValueValidator(0.001)
+        ]
+    )
+
+    class Meta:
+        model = StockItem
+        fields = [
+            'stock_item',
+            'quantity',
+        ]
+
+
 class UninstallStockForm(forms.ModelForm):
     """
     Form for uninstalling a stock item which is installed in another item.
diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py
index 4c86995cda..7ad8bc4f7f 100644
--- a/InvenTree/stock/urls.py
+++ b/InvenTree/stock/urls.py
@@ -25,6 +25,7 @@ stock_item_detail_urls = [
     url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
     url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
     url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
+    url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'),
 
     url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
 
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index c09c328c66..d818e82aa9 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -683,6 +683,46 @@ class StockItemQRCode(QRCodeView):
             return None
 
 
+class StockItemInstall(AjaxUpdateView):
+    """
+    View for manually installing stock items into
+    a particular stock item.
+
+    In contrast to the StockItemUninstall view,
+    only a single stock item can be installed at once.
+    
+    The "part" to be installed must be provided in the GET query parameters.
+    
+    """
+
+    model = StockItem
+    form_class = StockForms.InstallStockForm
+    ajax_form_title = _('Install Stock Item')
+
+    def get_form(self):
+
+        form = super().get_form()
+
+        return form
+
+    def post(self, request, *args, **kwargs):
+
+
+        form = self.get_form()
+        valid = False
+
+        valid = form.is_valid() and valid
+
+        if valid:
+            pass
+
+        data = {
+            'form_valid': valid,
+        }
+
+        return self.renderJsonResponse(request, form, data=data)
+
+
 class StockItemUninstall(AjaxView, FormMixin):
     """
     View for uninstalling one or more StockItems,

From fd22e713ff8359749be91e091815ccc1ed95a80c Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 20:50:06 +1100
Subject: [PATCH 28/77] Filter available stock items by Part reference

---
 InvenTree/stock/forms.py |  6 ++++--
 InvenTree/stock/views.py | 16 ++++++++++++++++
 2 files changed, 20 insertions(+), 2 deletions(-)

diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index ebcf31cb23..28d0b47ef3 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -283,8 +283,10 @@ class InstallStockForm(HelperForm):
         help_text=_('Stock item to install')
     )
 
-    quantity = RoundingDecimalFormField(
+    quantity_to_install = RoundingDecimalFormField(
         max_digits=10, decimal_places=5,
+        initial=1,
+        label=_('Quantity'),
         help_text=_('Stock quantity to assign'),
         validators=[
             MinValueValidator(0.001)
@@ -295,7 +297,7 @@ class InstallStockForm(HelperForm):
         model = StockItem
         fields = [
             'stock_item',
-            'quantity',
+            'quantity_to_install',
         ]
 
 
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index d818e82aa9..1f6ddcc0f4 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -703,6 +703,22 @@ class StockItemInstall(AjaxUpdateView):
 
         form = super().get_form()
 
+        queryset = form.fields['stock_item'].queryset
+
+        part = self.request.GET.get('part', None)
+
+        # Filter the available stock items based on the Part reference
+        if part:
+            try:
+                part = Part.objects.get(pk=part)
+
+                queryset = queryset.filter(part=part)
+
+            except (ValueError, Part.DoesNotExist):
+                pass
+
+        form.fields['stock_item'].queryset = queryset
+
         return form
 
     def post(self, request, *args, **kwargs):

From a686500df15a5315389d5b63a85a2a611884eb57 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 21:02:20 +1100
Subject: [PATCH 29/77] Calculate initial values for the view

---
 InvenTree/stock/models.py |  3 ++-
 InvenTree/stock/views.py  | 57 ++++++++++++++++++++++++++++-----------
 2 files changed, 44 insertions(+), 16 deletions(-)

diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index df1a628f47..62b774dd06 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -600,12 +600,13 @@ class StockItem(MPTTModel):
         return self.installedItemCount() > 0
 
     @transaction.atomic
-    def installIntoStockItem(self, otherItem, user, notes):
+    def installIntoStockItem(self, otherItem, quantity, user, notes):
         """
         Install this stock item into another stock item.
 
         Args
             otherItem: The stock item to install this item into
+            quantity: The quantity of stock to install
             user: The user performing the operation
             notes: Any notes associated with the operation
         """
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 1f6ddcc0f4..541c60b2f5 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -699,25 +699,52 @@ class StockItemInstall(AjaxUpdateView):
     form_class = StockForms.InstallStockForm
     ajax_form_title = _('Install Stock Item')
 
+    def get_stock_items(self):
+        """
+        Return a list of stock items suitable for displaying to the user.
+
+        Requirements:
+        - Items must be in stock
+        
+        Filters:
+        - Items can be filtered by Part reference
+        """
+
+        items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
+
+        # Filter by Part association
+        try:
+            part = self.request.GET.get('part', None)
+
+            print(self.request.GET)
+
+            if part is not None:
+                part = Part.objects.get(pk=part)
+                items = items.filter(part=part)
+        except (ValueError, Part.DoesNotExist):
+            pass
+
+        return items
+
+    def get_initial(self):
+
+        initials = super().get_initial()
+
+        items = self.get_stock_items()
+
+        # If there is a single stock item available, we can use it!
+        if items.count() == 1:
+            item = items.first()
+            initials['stock_item'] = item.pk
+            initials['quantity_to_install'] = item.quantity
+        
+        return initials
+
     def get_form(self):
 
         form = super().get_form()
 
-        queryset = form.fields['stock_item'].queryset
-
-        part = self.request.GET.get('part', None)
-
-        # Filter the available stock items based on the Part reference
-        if part:
-            try:
-                part = Part.objects.get(pk=part)
-
-                queryset = queryset.filter(part=part)
-
-            except (ValueError, Part.DoesNotExist):
-                pass
-
-        form.fields['stock_item'].queryset = queryset
+        form.fields['stock_item'].queryset = self.get_stock_items()
 
         return form
 

From 45c888e13d2df97512d688f2e00f84dafc6e7e3c Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 21:31:44 +1100
Subject: [PATCH 30/77] Custom cleaning for form

Ok, looks like I've been doing this wrong the whole time!
The "djangonic" way is pretty cool
---
 InvenTree/stock/forms.py | 21 +++++++++++++++++++++
 1 file changed, 21 insertions(+)

diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 28d0b47ef3..729831a9ec 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -9,6 +9,7 @@ from django import forms
 from django.forms.utils import ErrorDict
 from django.utils.translation import ugettext as _
 from django.core.validators import MinValueValidator
+from django.core.exceptions import ValidationError
 
 from mptt.fields import TreeNodeChoiceField
 
@@ -293,13 +294,33 @@ class InstallStockForm(HelperForm):
         ]
     )
 
+    notes = forms.CharField(
+        required=False,
+        help_text=_('Notes')
+    )
+
     class Meta:
         model = StockItem
         fields = [
             'stock_item',
             'quantity_to_install',
+            'notes',
         ]
 
+    def clean(self):
+
+        data = super().clean()
+
+        print("Data:", data)
+
+        stock_item = data['stock_item']
+        quantity = data['quantity_to_install']
+
+        if quantity > stock_item.quantity:
+            raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')})
+
+        return data
+        
 
 class UninstallStockForm(forms.ModelForm):
     """

From 9c27680202da2a0acd9a05c474ac58620b761e1e Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 21:32:21 +1100
Subject: [PATCH 31/77] Finish function to install stock item(s)

---
 InvenTree/stock/models.py | 31 +++++++++++++++++--------------
 InvenTree/stock/views.py  | 18 ++++++++++++------
 2 files changed, 29 insertions(+), 20 deletions(-)

diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 62b774dd06..964ab43e8a 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -600,12 +600,12 @@ class StockItem(MPTTModel):
         return self.installedItemCount() > 0
 
     @transaction.atomic
-    def installIntoStockItem(self, otherItem, quantity, user, notes):
+    def installStockItem(self, otherItem, quantity, user, notes):
         """
-        Install this stock item into another stock item.
+        Install another stock item into this stock item.
 
         Args
-            otherItem: The stock item to install this item into
+            otherItem: The stock item to install into this stock item
             quantity: The quantity of stock to install
             user: The user performing the operation
             notes: Any notes associated with the operation
@@ -615,16 +615,19 @@ class StockItem(MPTTModel):
         if self.belongs_to is not None:
             return False
 
-        # TODO - Are there any other checks that need to be performed at this stage?
+        # If the quantity is less than the stock item, split the stock!
+        stock_item = otherItem.splitStock(quantity, None, user)
 
-        # Mark this stock item as belonging to the other one
-        self.belongs_to = otherItem
-        
-        self.save()
+        if stock_item is None:
+            stock_item = otherItem
+
+        # Assign the other stock item into this one
+        stock_item.belongs_to = self
+        stock_item.save()
 
         # Add a transaction note!
-        self.addTransactionNote(
-            _('Installed in stock item') + ' ' + str(otherItem.pk),
+        stock_item.addTransactionNote(
+            _('Installed into stock item') + ' ' + str(self.pk),
             user,
             notes=notes
         )
@@ -839,20 +842,20 @@ class StockItem(MPTTModel):
 
         # Do not split a serialized part
         if self.serialized:
-            return
+            return self
 
         try:
             quantity = Decimal(quantity)
         except (InvalidOperation, ValueError):
-            return
+            return self
 
         # Doesn't make sense for a zero quantity
         if quantity <= 0:
-            return
+            return self
 
         # Also doesn't make sense to split the full amount
         if quantity >= self.quantity:
-            return
+            return self
 
         # Create a new StockItem object, duplicating relevant fields
         # Nullify the PK so a new record is created
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 541c60b2f5..fe2ffbe7c6 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -716,8 +716,6 @@ class StockItemInstall(AjaxUpdateView):
         try:
             part = self.request.GET.get('part', None)
 
-            print(self.request.GET)
-
             if part is not None:
                 part = Part.objects.get(pk=part)
                 items = items.filter(part=part)
@@ -750,14 +748,22 @@ class StockItemInstall(AjaxUpdateView):
 
     def post(self, request, *args, **kwargs):
 
-
         form = self.get_form()
-        valid = False
 
-        valid = form.is_valid() and valid
+        valid = form.is_valid()
 
         if valid:
-            pass
+            # We assume by this point that we have a valid stock_item and quantity values
+            data = form.cleaned_data
+
+            other_stock_item = data['stock_item']
+            quantity = data['quantity_to_install']
+            notes = data['notes']
+
+            # Install the other stock item into this one
+            this_stock_item = self.get_object()
+
+            this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
 
         data = {
             'form_valid': valid,

From 3c5968ef1ae21ddb3afd3ddc752b0e1cbdcaa28b Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 22:58:41 +1100
Subject: [PATCH 32/77] Add subrow table to the "installed items" view

Ah, javascript...
---
 .../stock/templates/stock/item_installed.html | 151 +---------------
 InvenTree/templates/js/stock.html             | 170 +++++++++++++++++-
 2 files changed, 174 insertions(+), 147 deletions(-)

diff --git a/InvenTree/stock/templates/stock/item_installed.html b/InvenTree/stock/templates/stock/item_installed.html
index cac55c9dce..2a6a0db057 100644
--- a/InvenTree/stock/templates/stock/item_installed.html
+++ b/InvenTree/stock/templates/stock/item_installed.html
@@ -10,19 +10,7 @@
 <h4>{% trans "Installed Stock Items" %}</h4>
 <hr>
 
-<div id='button-toolbar'>
-    <div class='button-toolbar container-fluid' style='float: right;'>
-        <div class="btn-group">
-            <button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
-            <ul class="dropdown-menu">
-                <li><a href="#" id='multi-item-uninstall' title='{% trans "Uninstall selected stock items" %}'>{% trans "Uninstall" %}</a></li>
-            </ul>
-        </div>
-    </div>
-</div>
-
-<table class='table table-striped table-condensed' id='installed-table' data-toolbar='#button-toolbar'>
-</table>
+<table class='table table-striped table-condensed' id='installed-table'></table>
 
 {% endblock %}
 
@@ -30,135 +18,14 @@
 
 {{ block.super }}
 
-$('#installed-table').inventreeTable({
-    formatNoMatches: function() {
-        return '{% trans "No stock items installed" %}';
-    },
-    url: "{% url 'api-stock-list' %}",
-    queryParams: {
-        installed_in: {{ item.id }},
-        part_detail: true,
-    },
-    name: 'stock-item-installed',
-    url: "{% url 'api-stock-list' %}",   
-    showColumns: true,
-    columns: [
-        {
-            checkbox: true,
-            title: '{% trans 'Select' %}',
-            searchable: false,
-            switchable: false,
-        },
-        {
-            field: 'pk',
-            title: 'ID',
-            visible: false,
-            switchable: false,
-        },
-        {
-            field: 'part_name',
-            title: '{% trans "Part" %}',
-            sortable: true,
-            formatter: function(value, row, index, field) {
-
-                var url = `/stock/item/${row.pk}/`;
-                var thumb = row.part_detail.thumbnail;
-                var name = row.part_detail.full_name;
-
-                html = imageHoverIcon(thumb) + renderLink(name, url);
-                
-                return html;
-            }
-        },
-        {
-            field: 'IPN',
-            title: 'IPN',
-            sortable: true,
-            formatter: function(value, row, index, field) {
-                return row.part_detail.IPN;
-            },
-        },
-        {
-            field: 'part_description',
-            title: '{% trans "Description" %}',
-            sortable: true,
-            formatter: function(value, row, index, field) {
-                return row.part_detail.description;
-            }
-        },
-        {
-            field: 'quantity',
-            title: '{% trans "Stock" %}',
-            sortable: true,
-            formatter: function(value, row, index, field) {
-
-                var val = parseFloat(value);
-
-                // If there is a single unit with a serial number, use the serial number
-                if (row.serial && row.quantity == 1) {
-                    val = '# ' + row.serial;
-                } else {
-                    val = +val.toFixed(5);
-                }
-
-                var html = renderLink(val, `/stock/item/${row.pk}/`);
-
-                return html;
-            }
-        },
-        {
-            field: 'status',
-            title: '{% trans "Status" %}',
-            sortable: 'true',
-            formatter: function(value, row, index, field) {
-                return stockStatusDisplay(value);
-            },
-        },
-        {
-            field: 'batch',
-            title: '{% trans "Batch" %}',
-            sortable: true,
-        },
-        {
-            field: 'actions',
-            switchable: false,
-            title: '',
-            formatter: function(value, row) {
-                var pk = row.pk;
-
-                var html = `<div class='btn-group float-right' role='group'>`;
-
-                html += makeIconButton('fa-unlink', 'button-uninstall', pk, '{% trans "Uninstall item" %}');
-
-                html += `</div>`;
-
-                return html;
-            }
-        }
-    ],
-    onLoadSuccess: function() {
-
-        var table = $('#installed-table');
-
-        // Find buttons and associate actions
-        table.find('.button-uninstall').click(function() {
-            var pk = $(this).attr('pk');
-
-            launchModalForm(
-                "{% url 'stock-item-uninstall' %}",
-                {
-                    data: {
-                        'items[]': [pk],
-                    },
-                    reload: true,
-                }
-            );
-        });
-    },
-    buttons: [
-        '#stock-options',
-    ]
-});
+loadInstalledInTable(
+    $('#installed-table'),
+    {
+        stock_item: {{ item.pk }},
+        part: {{ item.part.pk }},
+        quantity: {{ item.quantity }},
+    }
+);
 
 $('#multi-item-uninstall').click(function() {
 
diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html
index 4ec2507b31..330be924e5 100644
--- a/InvenTree/templates/js/stock.html
+++ b/InvenTree/templates/js/stock.html
@@ -830,8 +830,25 @@ function loadInstalledInTable(table, options) {
     *
     * - stock_item: The PK of the master stock_item object
     * - part: The PK of the Part reference of the stock_item object
+    * - quantity: The quantity of the stock item
     */
 
+    function updateCallbacks() {
+        // Setup callback functions when buttons are pressed
+        table.find('.button-install').click(function() {
+            var pk = $(this).attr('pk');
+
+            launchModalForm(
+                `/stock/item/${options.stock_item}/install/`,
+                {
+                    data: {
+                        part: pk,
+                    },
+                }
+            );
+        });
+    }
+
     table.inventreeTable(
         {
             url: "{% url 'api-bom-list' %}",
@@ -842,6 +859,92 @@ function loadInstalledInTable(table, options) {
             },
             showColumns: false,
             name: 'installed-in',
+            detailView: true,
+            detailViewByClick: true,
+            detailFilter: function(index, row) {
+                return row.installed_count && row.installed_count > 0;
+            },
+            detailFormatter: function(index, row, element) {
+                var subTableId = `installed-table-${row.sub_part}`;
+
+                var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
+
+                element.html(html);
+
+                var subTable = $(`#${subTableId}`);
+
+                // Display a "sub table" showing all the linked stock items
+                subTable.bootstrapTable({
+                    data: row.installed_items,
+                    showHeader: true,
+                    columns: [
+                        {
+                            field: 'item',
+                            title: '{% trans "Stock Item" %}', 
+                            formatter: function(value, subrow, index, field) {
+
+                                var pk = subrow.pk;
+                                var html = '';
+
+                                html += row.sub_part_detail.full_name;
+                                html += " | ";
+
+                                if (subrow.serial && subrow.quantity == 1) {
+                                    html += `{% trans "Serial" %}: ${subrow.serial}`;
+                                } else {
+                                    html += `{% trans "Quantity" %}: ${subrow.quantity}`; 
+                                }
+
+                                return html;
+                            },
+                        },
+                        {
+                            field: 'status',
+                            title: '{% trans "Status" %}',
+                            formatter: function(value, subrow, index, field) {
+                                return stockStatusDisplay(value);
+                            }
+                        },
+                        {
+                            field: 'actions',
+                            title: '',
+                            formatter: function(value, subrow, index) {
+
+                                var pk = subrow.pk;
+                                var html = '';
+
+                                // Add some buttons yo!
+                                html += `<div class='btn-group float-right' role='group'>`;
+                                
+                                html += makeIconButton('fa-unlink', 'button-uninstall', pk, "{% trans "Uninstall stock item" %}");
+
+                                html += `</div>`;
+
+                                return html;
+                            }
+                        }
+                    ],
+                    onPostBody: function() {
+                        // Setup button callbacks
+                        subTable.find('.button-uninstall').click(function() {
+                            var pk = $(this).attr('pk');
+
+                            launchModalForm(
+                                "{% url 'stock-item-uninstall' %}",
+                                {
+                                    data: {
+                                        'items[]': [pk],
+                                    },
+                                    success: function() {
+                                        // Refresh entire table!
+                                        table.bootstrapTable('refresh');
+                                    }
+                                }
+                            );
+                        });
+                    }
+                });
+            },
             columns: [
                 {
                     checkbox: true,
@@ -861,7 +964,7 @@ function loadInstalledInTable(table, options) {
                     sortable: true,
                     formatter: function(value, row, index, field) {
         
-                        var url = `/stock/item/${row.pk}/`;
+                        var url = `/part/${row.sub_part}/`;
                         var thumb = row.sub_part_detail.thumbnail;
                         var name = row.sub_part_detail.full_name;
         
@@ -877,9 +980,11 @@ function loadInstalledInTable(table, options) {
                     formatter: function(value, row, index, field) {
                         // Construct a progress showing how many items have been installed
 
-                        var installed = row.installed || 0;
+                        var installed = row.installed_count || 0;
                         var required = row.quantity || 0;
 
+                        required *= options.quantity;
+
                         var progress = makeProgressBar(installed, required, {
                             id: row.sub_part.pk,
                         });
@@ -891,7 +996,7 @@ function loadInstalledInTable(table, options) {
                     field: 'actions',
                     switchable: false,
                     formatter: function(value, row) {
-                        var pk = row.sub_part.pk;
+                        var pk = row.sub_part;
 
                         var html = `<div class='btn-group float-right' role='group'>`;
 
@@ -904,8 +1009,63 @@ function loadInstalledInTable(table, options) {
                 }
             ],
             onLoadSuccess: function() {
-                console.log('data loaded!');
-            }
+                // Grab a list of parts which are actually installed in this stock item
+
+                inventreeGet(
+                    "{% url 'api-stock-list' %}",
+                    {
+                        installed_in: options.stock_item,
+                    },
+                    {
+                        success: function(stock_items) {
+                            
+                            var table_data = table.bootstrapTable('getData');
+
+                            stock_items.forEach(function(item) {
+
+                                var match = false;
+                                
+                                for (var idx = 0; idx < table_data.length; idx++) {
+
+                                    var row = table_data[idx];
+
+                                    // Check each row in the table to see if this stock item matches
+                                    table_data.forEach(function(row) {
+
+                                        // Match on "sub_part"
+                                        if (row.sub_part == item.part) {
+                                            
+                                            // First time?
+                                            if (row.installed_count == null) {
+                                                row.installed_count = 0;
+                                                row.installed_items = [];
+                                            }
+                                            
+                                            row.installed_count += item.quantity;
+                                            row.installed_items.push(item);
+                                            
+                                            // Push the row back into the table
+                                            table.bootstrapTable('updateRow', idx, row, true);
+
+                                            match = true;
+                                        }
+
+                                    });
+
+                                    if (match) {
+                                        break;
+                                    }
+                                }
+                            });
+
+                            // Update button callback links
+                            updateCallbacks();
+                        }
+                    }
+                );
+
+                updateCallbacks();
+            },
         }
     );
 }
\ No newline at end of file

From 824ce6778fed69face76d505be0ffe614153714a Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 23:33:20 +1100
Subject: [PATCH 33/77] Progress bar tweaks

- If no maximum value supplied, just show the value (and fill to 100% width)
---
 .../static/script/inventree/inventree.js      | 23 +++++++++++++++----
 1 file changed, 19 insertions(+), 4 deletions(-)

diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js
index 45565f1d6a..263e28fc01 100644
--- a/InvenTree/InvenTree/static/script/inventree/inventree.js
+++ b/InvenTree/InvenTree/static/script/inventree/inventree.js
@@ -105,9 +105,14 @@ function makeProgressBar(value, maximum, opts) {
     var options = opts || {};
 
     value = parseFloat(value);
-    maximum = parseFloat(maximum);
 
-    var percent = parseInt(value / maximum * 100);
+    var percent = 100;
+
+    // Prevent div-by-zero or null value
+    if (maximum && maximum > 0) {
+        maximum = parseFloat(maximum);
+        percent = parseInt(value / maximum * 100);
+    }
 
     if (percent > 100) {
         percent = 100;
@@ -115,18 +120,28 @@ function makeProgressBar(value, maximum, opts) {
 
     var extraclass = '';
 
-    if (value > maximum) {
+    if (maximum) {
+        // TODO - Special color?
+    }
+    else if (value > maximum) {
         extraclass='progress-bar-over';
     } else if (value < maximum) {
         extraclass = 'progress-bar-under';
     }
 
+    var text = value;
+
+    if (maximum) {
+        text += ' / ';
+        text += maximum;
+    }
+
     var id = options.id || 'progress-bar';
 
     return `
     <div id='${id}' class='progress'>
         <div class='progress-bar ${extraclass}' role='progressbar' aria-valuenow='${percent}' aria-valuemin='0' aria-valuemax='100' style='width:${percent}%'></div>
-        <div class='progress-value'>${value} / ${maximum}</div>
+        <div class='progress-value'>${text}</div>
     </div>
     `;
 }

From b9291c6705a464ae7b0a1a3327bf7b4db4841dd5 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 23:33:43 +1100
Subject: [PATCH 34/77] Improve transaction note recording for the StockItem
 model

---
 InvenTree/stock/models.py | 29 ++++++++++++++++++++++++++---
 1 file changed, 26 insertions(+), 3 deletions(-)

diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 964ab43e8a..b78b0e9410 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -625,11 +625,19 @@ class StockItem(MPTTModel):
         stock_item.belongs_to = self
         stock_item.save()
 
-        # Add a transaction note!
+        # Add a transaction note to the other item
         stock_item.addTransactionNote(
             _('Installed into stock item') + ' ' + str(self.pk),
             user,
-            notes=notes
+            notes=notes,
+            url=self.get_absolute_url()
+        )
+
+        # Add a transaction note to this item
+        self.addTransactionNote(
+            _('Installed stock item') + ' ' + str(stock_item.pk),
+            user, notes=notes,
+            url=stock_item.get_absolute_url()
         )
 
     @transaction.atomic
@@ -649,16 +657,31 @@ class StockItem(MPTTModel):
 
         # TODO - Are there any other checks that need to be performed at this stage?
 
+        # Add a transaction note to the parent item
+        self.belongs_to.addTransactionNote(
+            _("Uninstalled stock item") + ' ' + str(self.pk),
+            user,
+            notes=notes,
+            url=self.get_absolute_url(),
+        )
+
+        # Mark this stock item as *not* belonging to anyone
         self.belongs_to = None
         self.location = location
 
         self.save()
 
+        if location:
+            url = location.get_absolute_url()
+        else:
+            url = ''
+
         # Add a transaction note!
         self.addTransactionNote(
             _('Uninstalled into location') + ' ' + str(location),
             user,
-            notes=notes
+            notes=notes,
+            url=url
         )
 
     @property

From 46f459b4c74462e554395225068df13db1934261 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 23:34:02 +1100
Subject: [PATCH 35/77] Better display of stock table

---
 InvenTree/templates/js/stock.html | 47 +++++++++++++++++++++++++++----
 1 file changed, 42 insertions(+), 5 deletions(-)

diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html
index 330be924e5..b3ea0f2baa 100644
--- a/InvenTree/templates/js/stock.html
+++ b/InvenTree/templates/js/stock.html
@@ -470,10 +470,16 @@ function loadStockTable(table, options) {
 
                     if (row.customer) {
                         html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
-                    } else if (row.build_order) {
-                        html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
-                    } else if (row.sales_order) {
-                        html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`;
+                    } else {
+                        if (row.build_order) {
+                            html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
+                        } else if (row.sales_order) {
+                            html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`;
+                        }
+                    }
+
+                    if (row.belongs_to) {
+                        html += `<span class='fas fa-box label-right' title='{% trans "Stock item has been installed in another item" %}'></span>`;
                     }
 
                     // Special stock status codes
@@ -520,6 +526,9 @@ function loadStockTable(table, options) {
                     } else if (row.customer) {
                         var text = "{% trans "Shipped to customer" %}";
                         return renderLink(text, `/company/${row.customer}/assigned-stock/`);
+                    } else if (row.sales_order) {
+                        var text = `{% trans "Assigned to sales order" %}`;
+                        return renderLink(text, `/order/sales-order/${row.sales_order}/`); 
                     }
                     else if (value) {
                         return renderLink(value, `/stock/location/${row.location}/`);
@@ -844,6 +853,10 @@ function loadInstalledInTable(table, options) {
                     data: {
                         part: pk,
                     },
+                    success: function() {
+                        // Refresh entire table!
+                        table.bootstrapTable('refresh');
+                    }
                 }
             );
         });
@@ -895,7 +908,7 @@ function loadInstalledInTable(table, options) {
                                     html += `{% trans "Quantity" %}: ${subrow.quantity}`; 
                                 }
 
-                                return html;
+                                return renderLink(html, `/stock/item/${subrow.pk}/`);
                             },
                         },
                         {
@@ -969,6 +982,10 @@ function loadInstalledInTable(table, options) {
                         var name = row.sub_part_detail.full_name;
         
                         html = imageHoverIcon(thumb) + renderLink(name, url);
+
+                        if (row.not_in_bom) {
+                            html = `<i>${html}</i>`
+                        }
                         
                         return html;
                     }
@@ -1015,6 +1032,7 @@ function loadInstalledInTable(table, options) {
                     "{% url 'api-stock-list' %}",
                     {
                         installed_in: options.stock_item,
+                        part_detail: true,
                     },
                     {
                         success: function(stock_items) {
@@ -1056,6 +1074,25 @@ function loadInstalledInTable(table, options) {
                                         break;
                                     }
                                 }
+
+                                if (!match) {
+                                    // The stock item did *not* match any items in the BOM!
+                                    // Add a new row to the table...
+
+                                    console.log("Found an unmatched part! " + item.pk + " -> " + item.part);
+                                    
+                                    // Contruct a new "row" to add to the table
+                                    var new_row = {
+                                        sub_part: item.part,
+                                        sub_part_detail: item.part_detail,
+                                        not_in_bom: true,
+                                        installed_count: item.quantity,
+                                        installed_items: [item],
+                                    };
+
+                                    table.bootstrapTable('append', [new_row]);
+
+                                }
                             });
 
                             // Update button callback links

From 42a75a82382b21f0f8137385fc45ad092fd22e4a Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 23:45:52 +1100
Subject: [PATCH 36/77] Add hidden input to the InstallStockForm form

- keeps track of "part" object
- so we can filter the stock_items queryset if the form validation fails
- Is there a more djangonic way of doing this??
---
 InvenTree/stock/forms.py |  8 ++++++++
 InvenTree/stock/views.py | 22 ++++++++++++++++------
 2 files changed, 24 insertions(+), 6 deletions(-)

diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 729831a9ec..85a520fed5 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -19,6 +19,8 @@ from InvenTree.fields import RoundingDecimalFormField
 
 from report.models import TestReport
 
+from part.models import Part
+
 from .models import StockLocation, StockItem, StockItemTracking
 from .models import StockItemAttachment
 from .models import StockItemTestResult
@@ -278,6 +280,11 @@ class InstallStockForm(HelperForm):
     Form for manually installing a stock item into another stock item
     """
 
+    part = forms.ModelChoiceField(
+        queryset=Part.objects.all(),
+        widget=forms.HiddenInput
+    )
+
     stock_item = forms.ModelChoiceField(
         required=True,
         queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER),
@@ -302,6 +309,7 @@ class InstallStockForm(HelperForm):
     class Meta:
         model = StockItem
         fields = [
+            'part',
             'stock_item',
             'quantity_to_install',
             'notes',
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index fe2ffbe7c6..860ef0c74b 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -699,6 +699,8 @@ class StockItemInstall(AjaxUpdateView):
     form_class = StockForms.InstallStockForm
     ajax_form_title = _('Install Stock Item')
 
+    part = None
+
     def get_stock_items(self):
         """
         Return a list of stock items suitable for displaying to the user.
@@ -713,14 +715,19 @@ class StockItemInstall(AjaxUpdateView):
         items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
 
         # Filter by Part association
-        try:
-            part = self.request.GET.get('part', None)
 
-            if part is not None:
-                part = Part.objects.get(pk=part)
-                items = items.filter(part=part)
+        # Look at GET params
+        part_id = self.request.GET.get('part', None)
+
+        if part_id is None:
+            # Look at POST params
+            part_id = self.request.POST.get('part', None)
+
+        try:
+            self.part = Part.objects.get(pk=part_id)
+            items = items.filter(part=self.part)
         except (ValueError, Part.DoesNotExist):
-            pass
+            self.part = None
 
         return items
 
@@ -735,6 +742,9 @@ class StockItemInstall(AjaxUpdateView):
             item = items.first()
             initials['stock_item'] = item.pk
             initials['quantity_to_install'] = item.quantity
+
+        if self.part:
+            initials['part'] = self.part
         
         return initials
 

From 852da6d696feac6d3241aaf8741280fbd4cc1194 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 23:48:15 +1100
Subject: [PATCH 37/77] Fix form validation

---
 InvenTree/stock/forms.py          | 8 ++++----
 InvenTree/templates/js/stock.html | 2 --
 2 files changed, 4 insertions(+), 6 deletions(-)

diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 85a520fed5..2502fc1869 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -282,7 +282,7 @@ class InstallStockForm(HelperForm):
 
     part = forms.ModelChoiceField(
         queryset=Part.objects.all(),
-        widget=forms.HiddenInput
+        widget=forms.HiddenInput()
     )
 
     stock_item = forms.ModelChoiceField(
@@ -321,10 +321,10 @@ class InstallStockForm(HelperForm):
 
         print("Data:", data)
 
-        stock_item = data['stock_item']
-        quantity = data['quantity_to_install']
+        stock_item = data.get('stock_item', None)
+        quantity = data.get('quantity_to_install', None)
 
-        if quantity > stock_item.quantity:
+        if stock_item and quantity and quantity > stock_item.quantity:
             raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')})
 
         return data
diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html
index b3ea0f2baa..0ebedbfb6e 100644
--- a/InvenTree/templates/js/stock.html
+++ b/InvenTree/templates/js/stock.html
@@ -1078,8 +1078,6 @@ function loadInstalledInTable(table, options) {
                                 if (!match) {
                                     // The stock item did *not* match any items in the BOM!
                                     // Add a new row to the table...
-
-                                    console.log("Found an unmatched part! " + item.pk + " -> " + item.part);
                                     
                                     // Contruct a new "row" to add to the table
                                     var new_row = {

From 3fe08862071824624d7b69dcaf32f077467e683f Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Sun, 4 Oct 2020 23:49:01 +1100
Subject: [PATCH 38/77] Remove a debug statement

---
 InvenTree/stock/forms.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 2502fc1869..548a03ae90 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -319,8 +319,6 @@ class InstallStockForm(HelperForm):
 
         data = super().clean()
 
-        print("Data:", data)
-
         stock_item = data.get('stock_item', None)
         quantity = data.get('quantity_to_install', None)
 

From 62734c4b7298fc1da248d644890ca225c64728a1 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 00:01:01 +1100
Subject: [PATCH 39/77] Add a custom template for the install item form

---
 .../stock/templates/stock/item_install.html     | 17 +++++++++++++++++
 .../stock/templates/stock/item_serialize.html   |  6 ++++--
 InvenTree/stock/views.py                        |  1 +
 InvenTree/templates/js/stock.html               |  7 ++++---
 4 files changed, 26 insertions(+), 5 deletions(-)
 create mode 100644 InvenTree/stock/templates/stock/item_install.html

diff --git a/InvenTree/stock/templates/stock/item_install.html b/InvenTree/stock/templates/stock/item_install.html
new file mode 100644
index 0000000000..04798972d2
--- /dev/null
+++ b/InvenTree/stock/templates/stock/item_install.html
@@ -0,0 +1,17 @@
+{% extends "modal_form.html" %}
+{% load i18n %}
+
+{% block pre_form_content %}
+
+<p>
+    {% trans "Install another StockItem into this item." %}
+</p>
+<p>
+    {% trans "Stock items can only be installed if they meet the following criteria" %}:
+
+    <ul>
+        <li>{% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}</li>
+        <li>{% trans "The StockItem is currently in stock" %}</li>
+    </ul>
+</p>
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/stock/templates/stock/item_serialize.html b/InvenTree/stock/templates/stock/item_serialize.html
index bb0054cca2..0f70647e38 100644
--- a/InvenTree/stock/templates/stock/item_serialize.html
+++ b/InvenTree/stock/templates/stock/item_serialize.html
@@ -1,6 +1,8 @@
 {% extends "modal_form.html" %}
+{% load i18n %}
 
 {% block pre_form_content %}
-Create serialized items from this stock item.<br>
-Select quantity to serialize, and unique serial numbers.
+{% trans "Create serialized items from this stock item." %}
+<br>
+{% trans "Select quantity to serialize, and unique serial numbers." %}
 {% endblock %}
\ No newline at end of file
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 860ef0c74b..9d078bf702 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -698,6 +698,7 @@ class StockItemInstall(AjaxUpdateView):
     model = StockItem
     form_class = StockForms.InstallStockForm
     ajax_form_title = _('Install Stock Item')
+    ajax_template_name = "stock/item_install.html"
 
     part = None
 
diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html
index 0ebedbfb6e..eb5e4adbf7 100644
--- a/InvenTree/templates/js/stock.html
+++ b/InvenTree/templates/js/stock.html
@@ -899,9 +899,6 @@ function loadInstalledInTable(table, options) {
                                 var pk = subrow.pk;
                                 var html = '';
 
-                                html += row.sub_part_detail.full_name;
-                                html += " | ";
-
                                 if (subrow.serial && subrow.quantity == 1) {
                                     html += `{% trans "Serial" %}: ${subrow.serial}`;
                                 } else {
@@ -918,6 +915,10 @@ function loadInstalledInTable(table, options) {
                                 return stockStatusDisplay(value);
                             }
                         },
+                        {
+                            field: 'batch',
+                            title: '{% trans "Batch" %}',
+                        },
                         {
                             field: 'actions',
                             title: '',

From ee28b4eea55e6f354531f125cb74af456ee320ff Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 00:12:42 +1100
Subject: [PATCH 40/77] Add "is_building" field to StockItem model

- This will be set to TRUE until a stock item has been completed
---
 .../migrations/0052_stockitem_is_building.py   | 18 ++++++++++++++++++
 InvenTree/stock/models.py                      |  5 +++++
 2 files changed, 23 insertions(+)
 create mode 100644 InvenTree/stock/migrations/0052_stockitem_is_building.py

diff --git a/InvenTree/stock/migrations/0052_stockitem_is_building.py b/InvenTree/stock/migrations/0052_stockitem_is_building.py
new file mode 100644
index 0000000000..46847992cc
--- /dev/null
+++ b/InvenTree/stock/migrations/0052_stockitem_is_building.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.7 on 2020-10-04 13:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('stock', '0051_auto_20200928_0928'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='stockitem',
+            name='is_building',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index b78b0e9410..75d38280d0 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -130,6 +130,7 @@ class StockItem(MPTTModel):
         status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
         notes: Extra notes field
         build: Link to a Build (if this stock item was created from a build)
+        is_building: Boolean field indicating if this stock item is currently being built
         purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
         infinite: If True this StockItem can never be exhausted
         sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
@@ -389,6 +390,10 @@ class StockItem(MPTTModel):
         related_name='build_outputs',
     )
 
+    is_building = models.BooleanField(
+        default=False,
+    )
+
     purchase_order = models.ForeignKey(
         'order.PurchaseOrder',
         on_delete=models.SET_NULL,

From 26d113e8adc5681d297cff7d434e0331dc8e16c1 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 00:14:04 +1100
Subject: [PATCH 41/77] Update IN_STOCK_FILTER to reject stock items which have
 is_building set to True

---
 InvenTree/stock/models.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 75d38280d0..3277137a25 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -143,6 +143,7 @@ class StockItem(MPTTModel):
         build_order=None,
         belongs_to=None,
         customer=None,
+        is_building=False,
         status__in=StockStatus.AVAILABLE_CODES
     )
 

From fe3a72c6cc878787f1bc427d700173d4527b990a Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 00:29:06 +1100
Subject: [PATCH 42/77] Add some unit testing

---
 InvenTree/part/models.py  |  9 +++++----
 InvenTree/stock/models.py |  4 ++++
 InvenTree/stock/tests.py  | 24 ++++++++++++++++++++++++
 3 files changed, 33 insertions(+), 4 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 1400abd225..5f29c8cf80 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -784,12 +784,13 @@ class Part(MPTTModel):
         """ Return the current number of parts currently being built
         """
 
-        quantity = self.active_builds.aggregate(quantity=Sum('quantity'))['quantity']
+        stock_items = self.stock_items.filter(is_building=True)
 
-        if quantity is None:
-            quantity = 0
+        query = stock_items.aggregate(
+            quantity=Coalesce(Sum('quantity'), Decimal(0))
+        )
 
-        return quantity
+        return query['quantity']
 
     def build_order_allocations(self):
         """
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 3277137a25..fbdc1654f3 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -721,6 +721,10 @@ class StockItem(MPTTModel):
         if self.customer is not None:
             return False
 
+        # Not 'in stock' if it is building
+        if self.is_building:
+            return False
+
         # Not 'in stock' if the status code makes it unavailable
         if self.status in StockStatus.UNAVAILABLE_CODES:
             return False
diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
index 23cf6b3d02..135cb48f8b 100644
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -47,6 +47,30 @@ class StockTest(TestCase):
         Part.objects.rebuild()
         StockItem.objects.rebuild()
 
+    def test_is_building(self):
+        """
+        Test that the is_building flag does not count towards stock.
+        """
+
+        part = Part.objects.get(pk=1)
+
+        # Record the total stock count
+        n = part.total_stock
+
+        StockItem.objects.create(part=part, quantity=5)
+
+        # And there should be *no* items being build
+        self.assertEqual(part.quantity_being_built, 0)
+
+        # Add some stock items which are "building"
+        for i in range(10):
+            item = StockItem.objects.create(part=part, quantity=10, is_building=True)
+
+        # The "is_building" quantity should not be counted here
+        self.assertEqual(part.total_stock, n + 5)
+
+        self.assertEqual(part.quantity_being_built, 100)
+
     def test_loc_count(self):
         self.assertEqual(StockLocation.objects.count(), 7)
 

From c1595396c44a683e65f554a247c8014a5b12d8e9 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 00:29:31 +1100
Subject: [PATCH 43/77] Unit testing: fix PEP issues

---
 InvenTree/stock/tests.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
index 135cb48f8b..44b901e7b6 100644
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -64,7 +64,7 @@ class StockTest(TestCase):
 
         # Add some stock items which are "building"
         for i in range(10):
-            item = StockItem.objects.create(part=part, quantity=10, is_building=True)
+            StockItem.objects.create(part=part, quantity=10, is_building=True)
 
         # The "is_building" quantity should not be counted here
         self.assertEqual(part.total_stock, n + 5)

From 3ee7be1d5845760d41f690d298bc5c8ebd6f92e6 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 00:42:09 +1100
Subject: [PATCH 44/77] Add "optional" field to BomItem

- Defaults to False
- Indicates that the BomItem is "optional" for a build
- Will be used in the future when calculating if a Build output is fully allocated!
---
 InvenTree/part/api.py                          | 16 +++++++++++++---
 InvenTree/part/forms.py                        |  3 ++-
 .../part/migrations/0051_bomitem_optional.py   | 18 ++++++++++++++++++
 InvenTree/part/models.py                       |  3 +++
 InvenTree/part/serializers.py                  |  1 +
 InvenTree/templates/js/bom.html                |  4 ++++
 6 files changed, 41 insertions(+), 4 deletions(-)
 create mode 100644 InvenTree/part/migrations/0051_bomitem_optional.py

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 9a86bb98d5..6834503466 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -765,20 +765,30 @@ class BomList(generics.ListCreateAPIView):
 
         queryset = super().filter_queryset(queryset)
 
+        params = self.request.query_params
+
+        # Filter by "optional" status?
+        optional = params.get('optional', None)
+
+        if optional is not None:
+            optional = str2bool(optional)
+
+            queryset = queryset.filter(optional=optional)
+
         # Filter by part?
-        part = self.request.query_params.get('part', None)
+        part = params.get('part', None)
 
         if part is not None:
             queryset = queryset.filter(part=part)
         
         # Filter by sub-part?
-        sub_part = self.request.query_params.get('sub_part', None)
+        sub_part = params.get('sub_part', None)
 
         if sub_part is not None:
             queryset = queryset.filter(sub_part=sub_part)
 
         # Filter by "trackable" status of the sub-part
-        trackable = self.request.query_params.get('trackable', None)
+        trackable = params.get('trackable', None)
 
         if trackable is not None:
             trackable = str2bool(trackable)
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 0a15d598bd..c64bbb8362 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -231,7 +231,8 @@ class EditBomItemForm(HelperForm):
             'quantity',
             'reference',
             'overage',
-            'note'
+            'note',
+            'optional',
         ]
 
         # Prevent editing of the part associated with this BomItem
diff --git a/InvenTree/part/migrations/0051_bomitem_optional.py b/InvenTree/part/migrations/0051_bomitem_optional.py
new file mode 100644
index 0000000000..04920b3c10
--- /dev/null
+++ b/InvenTree/part/migrations/0051_bomitem_optional.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.7 on 2020-10-04 13:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0050_auto_20200917_2315'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='bomitem',
+            name='optional',
+            field=models.BooleanField(default=False, help_text='This BOM item is optional'),
+        ),
+    ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 5f29c8cf80..d427409c71 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -1500,6 +1500,7 @@ class BomItem(models.Model):
         part: Link to the parent part (the part that will be produced)
         sub_part: Link to the child part (the part that will be consumed)
         quantity: Number of 'sub_parts' consumed to produce one 'part'
+        optional: Boolean field describing if this BomItem is optional
         reference: BOM reference field (e.g. part designators)
         overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
         note: Note field for this BOM item
@@ -1533,6 +1534,8 @@ class BomItem(models.Model):
     # Quantity required
     quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], help_text=_('BOM quantity for this BOM item'))
 
+    optional = models.BooleanField(default=False, help_text=_("This BOM item is optional"))
+
     overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
                                help_text=_('Estimated build wastage quantity (absolute or percentage)')
                                )
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 7c73e9f98b..847216e957 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -403,6 +403,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
             'quantity',
             'reference',
             'price_range',
+            'optional',
             'overage',
             'note',
             'validated',
diff --git a/InvenTree/templates/js/bom.html b/InvenTree/templates/js/bom.html
index db5eafa0c1..b804453ca2 100644
--- a/InvenTree/templates/js/bom.html
+++ b/InvenTree/templates/js/bom.html
@@ -169,6 +169,10 @@ function loadBomTable(table, options) {
             // Let's make it a bit more pretty
             text = parseFloat(text);
 
+            if (row.optional) {
+                text += " ({% trans "Optional" %})";    
+            }
+
             if (row.overage) {
                 text += "<small> (+" + row.overage + ")    </small>";
             }

From 48e050d3171c16f16d92feae7ff63d08b960818e Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 00:49:00 +1100
Subject: [PATCH 45/77] Add some more unit tests and validation code for the
 StockItem model

- Ensure that the build part matches the stockitem part!
---
 InvenTree/stock/models.py | 14 ++++++++++++++
 InvenTree/stock/tests.py  |  9 ++++++++-
 2 files changed, 22 insertions(+), 1 deletion(-)

diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index fbdc1654f3..1535ded420 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -275,11 +275,25 @@ class StockItem(MPTTModel):
             # TODO - Find a test than can be perfomed...
             pass
 
+        # Ensure that the item cannot be assigned to itself
         if self.belongs_to and self.belongs_to.pk == self.pk:
             raise ValidationError({
                 'belongs_to': _('Item cannot belong to itself')
             })
 
+        # If the item is marked as "is_building", it must point to a build!
+        if self.is_building and not self.build:
+            raise ValidationError({
+                'build': _("Item must have a build reference if is_building=True")
+            })
+
+        # If the item points to a build, check that the Part references match
+        if self.build:
+            if not self.part == self.build.part:
+                raise ValidationError({
+                    'build': _("Build reference does not point to the same part object")
+                })
+
     def get_absolute_url(self):
         return reverse('stock-item-detail', kwargs={'pk': self.id})
 
diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
index 44b901e7b6..34fe8877f8 100644
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -7,7 +7,9 @@ import datetime
 
 from .models import StockLocation, StockItem, StockItemTracking
 from .models import StockItemTestResult
+
 from part.models import Part
+from build.models import Build
 
 
 class StockTest(TestCase):
@@ -62,9 +64,14 @@ class StockTest(TestCase):
         # And there should be *no* items being build
         self.assertEqual(part.quantity_being_built, 0)
 
+        build = Build.objects.create(part=part, title='A test build', quantity=1)
+
         # Add some stock items which are "building"
         for i in range(10):
-            StockItem.objects.create(part=part, quantity=10, is_building=True)
+            StockItem.objects.create(
+                part=part, build=build,
+                quantity=10, is_building=True
+            )
 
         # The "is_building" quantity should not be counted here
         self.assertEqual(part.total_stock, n + 5)

From 13cd8624b2a5a7bdec5c09b810126fb7188fff6b Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 01:01:56 +1100
Subject: [PATCH 46/77] Fix permissions

---
 InvenTree/templates/navbar.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index 57a902b755..2224d85bc4 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -21,7 +21,7 @@
         {% if perms.stock.view_stockitem or perms.part.view_stocklocation %}
         <li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
         {% endif %}
-        {% if perms.build %}
+        {% if perms.build.view_build %}
         <li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
         {% endif %}
         {% if perms.order.view_purchaseorder %}

From bce7eb1aad16e36300cdf94c7f63760fc9696c9f Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 01:02:36 +1100
Subject: [PATCH 47/77] update translation files

---
 InvenTree/locale/de/LC_MESSAGES/django.mo |  Bin 49527 -> 49430 bytes
 InvenTree/locale/de/LC_MESSAGES/django.po | 1089 ++++++++++++---------
 InvenTree/locale/en/LC_MESSAGES/django.po | 1041 +++++++++++---------
 InvenTree/locale/es/LC_MESSAGES/django.po | 1041 +++++++++++---------
 4 files changed, 1748 insertions(+), 1423 deletions(-)

diff --git a/InvenTree/locale/de/LC_MESSAGES/django.mo b/InvenTree/locale/de/LC_MESSAGES/django.mo
index 6c5d41663c4d432a6b181ba9b0917325f50dcb2c..6391f0ae5a5d9db6fe8c2b5bbd59d2a7fd8e05ab 100644
GIT binary patch
delta 15503
zcmYk?1$39yAII^B+6D{8fNk`E(Tu_9ZlzngyQEVlk04zNBPFCpN=^`@QBV+2Fo3@p
zm`Di<3JUmte|En+hv%H@>)!8;=hpL^f6xE#sTr0|&ES6<k#UK~b3VQ2<;7`{p7&7(
z&%0e(QO^si;dxmxGX`KT48a%-!#E7Z8mM+nFduft+&IDFNf=Fe1LnoUn9cKi?<$$>
z1a7&2_aA1aoUNwkg<~F6$I=*x4N((lj+wACs(nw?1O}rf_6kPfY~)4WI*X^6Cowbc
z_b!rA$FH$2-ojiMSIhY#mZjVa<8Tqu#5->BXJ&kD&x<GC6IK5vY9bdf3x0z^c*DGp
z5k7%`$OL0(9k=3Ks4Xptd9jN55^ANRP!mo>PRg5yioc0^;aeDiTTv@Nj9SnI%#Sxv
z?E_h@uS_;F+R8krnHIztERE{W95v8TD^EtXUyYjR2Gj!fm`AWb<uj<GDqGLBtBIOm
zQ&hZjJ@#K62N2K<N2AVqJ{H1FsQNRg4qu`s@&oE@AE53|NPV}pF{n#g0<|+`Q46eK
z*23bH8)11IS)ctcOeUE?DLjLE(L*zR16R(9+S0tJ0psxntcum}H7tXNto|nItRG+w
ze2Tiv5e?l=6h?g=<$N*~$TUN(YznGl66&_ELhZmtb2n-NhpqlJ>MmSB9mTg+zKvSQ
z6Vwi6X0|dPYW#AjqwuR)rZH;Zj%IJvQ4B>*WIE==`KXz1u==Bzhw^1CfPY$j_7~j|
zl|g<|ycbaYyP?J%?ex8QWYjSkbrh#C5Pw5;xR2Vxz(#H-@}dSTj*8bt?OZ#n?~j_$
zDAdj@K&^a@mG`2?J&F1C{a>-b->5SVZS1xt233wn4N%W)kGgz=Q7@Q^8gLQn5^q7Z
zKZ#oDSEzQsTlqg!yXYn?g!g+1WE5zO>d*(Z1BsXgr=eE(25R7SsLQkuwG*dNEB*#`
z2OeYUjx}}T<wdoth&uCT7=&HW*9!ZR$%o@HGcG|*;BC|kH)BRTgnIFD^CFg_{DT?R
z%<V)q3?SYLHSrFp9T<cmI1x3G8O_*#RV*Q(fs;`)-H%$qdDPbafchbNgdrH!+#O9U
zs$3cMq6QXkkLouFwKG0yyp^c;?MAgf-kkl{i!WK=N7UIqM0E&l;ePWAp?0bjs$(Bi
zyRqhc45pln`a1Sl{G9od#h;*dII5-F;etMyFajm8I99<z*b8HE8kWJWR=$F2{}eTW
zkXCM`v8W@ehPp$wF)MaNo%KKr#gV3uT97}VjJ9yCRqQ~$;E**qi@F2fp#B)$N9{~l
zYqxWSQS}w9+z7Sxolp}PZuLIuk|&{FyaSn#?;R(jh8IvT{uZ;~9n?UNP#wbBxQP}-
zZE+dQgpIA-5_M?@qgFZ!`7QEZ!%FxO>TW$l{odqk%O5y>{}svT?0TbqfQF%N^&%{S
zN3kmYgu2!F+qo?+f_hOc%!bWSN6;NLULVxX4o5945!HV#>JBVYS>OLEGCHFTm;v8G
zZS`){OwXfs=nksgQ#0Tt_f=#?eZFN-ccvNU#F3Z-XIgv}YC<Pa6Tg7IGS|qc;Z4*Q
z{fGM8BHKGFVg%)Os52aZkvIjzak<5}qAukJs2%wlHL*LW7YBD>w=f!uVTTUvzZ&=!
zSdE&<DbyDJ54B~#pmyXAMq!qYZUu!<cc3h4iz}ff*buc-JuE&DHG%P{ot%c*adAiX
zUo+Z5Kxg->Roq5R#Ovg?G$-bySPkQ_73u|(F?EYkJFyD2qdQO&JA_)m3Do!>qt5=k
zdD$nUGrx|S;m;V2f1m~m?(DWY7phzq^@~;u^*hlS_2O4iU(Eu{g)30wq@Z4W4mJKo
z)DC=$+F}2$Rs3THc5wsdK+UuW>c#P>71u=Fg%?l*bwX|ZAk+&cpw51})i1;JlsBVx
zY8$HmF=Ra7yF^B3@il73zoTXr)YYAN4pfJ7sCEs^jusz^>gS{G#&Rr(yD<_kp(gkn
zY66ce9@<U)IR98O`pjyg&bS+DpwXBQ=URL#YQSTt`mazEx{KQ4^xfT=2cjkvY8J&>
zlxw4QVlrwcr(ihm_vVn%FV{-crP+Ww%dM!Lc-K5`@pD$bj5>lJQCt5fYU{lou3rRd
zocySEB~b0ETKx;?>vpywQx-d6UYvuvT$@oH&Z4&PGU_OPK@IrW%-qxSic-#ndSM+@
zzow`KbTs>-CO87)aB5HXUt71EKmzVV-R2vZ`U2GD%iPNi7=^l&v8Wf7LA9%D^^MK;
zR^JD8WW!M7Oh+AE5^BQRd--lAmk8t|;PrODc=@pe#hMs{1F#^@LJhPH^_d+&P52V(
za{Y+8@e%58NA^DaeZi_&28W{-um&~$7N3l^_7WDr>sS)g^>s5Wjj@#5qUtB0Ubq}}
zSGHPw7wQguYVpgct^Wpf#J{2X-LrDKey+bCL`EIMP`5b})v+XMVwF%UYKYp(o~V_M
zM{WH)EQG62XMPNIyU(Gv_)9E{Pp}xq_1B+W&wB~!=X+Dh=yOR%ZRt_0j8{;XF?@jA
z@;KDxsgC)vIci6SqFy);wY5u7?cPQm*&Zu@j#}8ysQ28*5Pko_1Kr>KXw(a9pa$%O
z+OpnO9&ILK2IA8(5ND%Sz7X|-HK<Fr4Yi{uQ4{(K_4{%I)&3b)=lx#rAonjEFJeZ@
zqcH#{ppL+|@&eS%m!JmRg&Ob#>L@N_dHfZ1N%IVLe=AC$+BL)?*aa)$6!cYbfJ`2|
zfIaXgYUWLbxEFRuefJYkE1ZkE1Itif%{x{;Yvu1y6MBl8Xy{O90n`M`p%&V2DEqIO
z4kn-(O+Za#7wU{Z!4h~AHDHdH-HT&U16RdZY>K+1Bd`|E#UMP3n&{^kk2g>|8a>SY
zeiR$V{%0YOKp+%rqRzA_Ho&&17cEC!(ru^-et??rcNl_^!`%c6qjt1B>h3hgFzk#P
zcM$45BT@Z)pG;OV^H396g*yAqSQvMrR(J_B;oqnMo}yM3Ji_&hK^;*k)Qf9bxg{2$
z+#7>&D)L+BEkI4ozeh&j^?#TZBS*T5qNo{GL~U(7RDCy$$Cpu`*J{*6kD=QCX!Z9{
zI~6m^-K83+iMK+v>yFuYzc+-8&gxZbFvDDo+RF8)l^#G{%JZn1U&jc%XXOl|-49L#
z>QYuky|4r7tLcZDkdK*hB?jsHPbQ;=JFz$(My=>)Ou)=z7z}G*X&i^T8yirU^ad8i
zz_IT4qYUx|dQDLi*@v3I=cpgHI~a#W#*uix*Op8voQVA0@RG4H290-T-Uc<}j;NjL
zjX7`#YND^9?#@OG$6cs)$B`}e&Y<qj*Qg)3>!|+EF!g``XP@A<x+rQ(YoI!|L=D&r
zwZc)TTf71@;yTn>ZbG$BLEV*8s26^Zn%FO>3Eju+_z!BFu!-!yGLaMArOJ;gw?M6|
zJ?fIZf*EiX_D|<|Yf+amaFTmb6lx;*PzxxDT47C$#x|G>hoB}n-QsH}vHv9r93)Tz
zf5My?mgv5USkwU3Q8RC64n*CRMAU?nQT=wHR{jBM<!4Yk^M%#_f?CiM)Oh**SKVz+
zKn+kA)v*JrV{dGUV=xbXh?>y%SRWswE@92dTsZ8CRd5T|#p_rd3$xG`*aLN!cB0<n
zAF#j&7C4WZ*%j28-9~NcLyW|r*W3yUm~~MD^+9d%Fw_stEYt!PV*%WR+OgB9BlsQJ
zao@`_g+B-cDxhZ8A2r|z)R|ARxR3fJoQ(x>C+bC?p?2mPs{JEW|0kGwN2a=R6!s!s
z2z6JcVX(gch1OuTxdk<m6x6^+Fbc0?e!P#`nef-$z`0OANU^AumPZ|BThveaAk^0~
z!pdV&6G)W2-<wUQGA_lccmYdc&@}hohAW_cpa!7^9&gS^9nA*Rg!f}pJdSlRWV-uv
z+Z^@6DX4Y}P)E5OeVx%RG8*Uv=EW~izfkv4^#x|Qm6SsbPz5z$J=77jwE8ZXiE?k$
zga@MfkF)YD)X^+O9nHEK?7tfBArOGaP#sRAR(i$acTpW5Vo~&Fx)&Ei9Z7lAirQFt
z5ay#i3ya_;i=V?7%C}ILJY*L8uQQ98<p!#Qg(&wx?Z8x2{ca4z&rlP-g4%&!%s)}L
z+MDerk_Ytzlz>|4ix`6)EIt7>;rTupZT&{nir+&W!Fh|{L=Es1^-~-=$F<LodSN-#
zi<+T+7kZ%veg(Ay3sF0?%jz$n#{U)dUO(ep_u?qjEswKuL)1WBP#s2L5Waz0`Et|%
z$*8S7V4g!w?0eLU|3$UWJ<p9-4E0sja{69pGWvYRp)SvKE6+u({4ET?Z5WJuP%A!y
zYJUlJXRe?oaueh68R`hj%y;d(q82a`HSQ~zQ{Vp_G9d)kq9(EfwX(ygfj>nJd>yqT
z56zGTZi@?}`qf1pVJp;8^+!!~yv66C##@iY@Li1N{oXY)>hKUXvw$~T!$`9@YC_de
zXWSgiV1HD<C04%)^}>UwBfEfF*e$Dngkh9}-*h{f8&kjkqGU2tQ3Z9&>!BJpL46IK
zurMZ~F4bC$$D^o~+{JK=T<9iP40Q+UU>59xnpl6#hLbS$_Ag}rb?G(|(8>;@w(v{T
zfVWUv|HKSm<l4oWHBp~ud(?`@p~g!>jk6si@C0i7FHkSOgX)(tiTy7@CN|0a^=g6Y
zFc7ohB+QQUQ3J0-bv%grYj+j3Q-7i+kbbedlm$@bBB)DO54E7CSPQ#i1zhQq(bj&3
z8t{Qtge`F$>SGM?c31<)qB`!yviK|NtB79eCK!vMlxtuPY=ZhqdZ60%!>l+Gv!g$S
zj4sI%)J)f-?!b041@*liLaqEV>Q4NP+L7GL+}%k)^{avUiEe<!aR^qy#i+Y;9<?*K
zupsaEf|k22EQy+7Rn!U^qGs3@bK(Gu#aB@~wjLw#wD}E2QGS5h>W~#~2lAof38*7&
zf?7yd4Ab|YNTwlyxv0Bv5jE3)F#&VE<zCbbHK9JJTRk3i`=??q+=lw0`q1h>L5=ej
zYR4X+CJ?pK9YG1q!TY_cWOV6TpkB}mtK(SAh<h+M?l&)@2E2{hfvl_Czd;p69bre*
zj`c$A#CX(>&O&|WOU<?DtHU-jnpq0!a-6~p_$6wkS5Yte$;$s?5y}CpUA#2LP_B=<
zOMOw7ZzO8G8JHKBq9&GtTG*M@?7z0|0s+nR2I_^6QFkHZ8h1CsQS}8-1C}-GpxU>x
zau?J}hoBZT5!G)l>cy+9elKbQ$Jem`dB~h1pc!6At>77I#ldg8Gc1I9aUG1oR;U+@
zwDRkyg=|FiJBs=7OVs!eP?tA+t!q~iwL>j^GJFPJUsT5#s1B=9XM7N&@fvD^4^f|0
z#5%X4>ZrTX8uh}VsEN!)efP<j`Z=S<NkNTs4%?ys8=3TEYOQydr!HzDO|0AtwZ(%`
zTRRrDm9L}Ptw8+(?ndp<G4oSY`|nUMzGLx#4X%A8ax}hIgp3+iLTzaS)Dg7DD%b~0
z;wse2KSJH&d#JDFA5_1vjm{X<gc7hU)<v}+g<43W)z8G#|Ngg>j6S~&sPBJ2X2kEY
zKK_jQN=hWV%ankcaDB5iYTzDLKiJ}vP!pMrTF`RTrA|iuAnj4TPv(pTu3$Xndsq%*
zHo0HAHmIM}aaa*oqbB+}md2;3tuMLR-GOSTqv?ct(Fk)EY6sV113ZI%RWe~)+)Nvx
zCeQ&j;7rt|T84V@ek_Hzu`Wh$b(gm@>NY20CR~Vxa2aZ6-p4>Zg*vkHm>I8c<@~iZ
zw+IB`Bh&!twz-ahsM{TZ>JW$8!pf)#)<<n|ThtxsjT&zZ>MNRpdhu!uz+}`<`a9-#
z+t`0yl8Eij`luC;F_TcYcQ4k&PcRlUzvCuQ26cC8VLoha4o6LRK5AjhPzza$Meq=6
zC$9Tsw1S5ihZ%Rcl_X#R$_-E*`l1G$gZjP~qx$Vcz2FFDz|$ClXHhS>j9Ks&^0|3`
zp>{5Cr<=GRMMe!vqB_<>-TJ0j9N$2__yFo?PGT5-j2ieds@)H$ae{Zb--iNbMXXG`
zDQ3mjQ9F_3^u3*Aw54ZIE4_<4f*iZuB`S~oDYr-c@Ek$ae~!A{e_&S3vd7)}+*qD+
zIn++|LyhaB`X{5ljzd^jfB!F$sZHQ7)JiMubvsc9n^JC$I@=Xk5Z}Xccoj8ZaEcqS
zAnLAEL0!Jqs3UwC^+PrvL-90fVqaia-tXNdlMS1`>n6|*^*Ied9Z90aXJaeMNvIe9
zgnH3Gs2z#i=l-K~Mbw=biaM&Xs2!SS<;AG?Z9re2$38Ne*%9*-jG%lKgYh=%^Ld2D
z@ISNIemB9Es26obO=t+J-%QMjNthkCnnzH7gf8y4@Bc1=W(594&Aicju0d<m0K-rd
zorW5C9_rF9M@{quR>Lo_BxXI}emSe5Ce|Hwsb9tz+=E5&lK$&gIGM);v;$cVx~-2!
z-R?Nl)>SbZp(fN3BXJmNE2pEzS%#X>7K<Ok1j^@8<9P2o!!d?pj8CQnnYySK4neJS
z9O~>RqgFH*b&L07dAx=-F#M4F;cAZcC{IQm!3oq3eS^9i0f*T=EP}es+fbL;|Co&a
z7~I6d7=Oe~s4d1(eig^yc6<qAj=JB0aj30LL><{&)QcBmH10<2#2ND%>g)O)wR4YM
z+4sVaxs~Ncb*zJG&<S(l2-KF(K@GSTb6^T;=RQX5+#jfkJVi|`;JEvJ$cY*+4l`l{
z)Xp}>)c^l)dooc3dZGrHfI5OHs29w(atfBBe8~J2(^JlVf<IWON8`j0{4gy*d9NyX
z9$D;b)PyF}E{jj$FROSPPmqF1g{a)1(RntI>L|lg%G&m)ZZi4TNXv-7MrurK9qBf)
zwUi5x*TczrIf>1pyo&r+<kKEsnKwu;5PXdWRY_eaZzjc%GE%N(9e$^*=e{HLpGB$H
z6OBi$T~_jIEdMuk8At`xi02mZB{(Ow#P|OjnNMtx!ML0TbFA}Oi&w_=#FB8I)y*W|
zi`10<mq^9Qm!^&%LvIGAqdXR;;1ptd=2BivIqmr*U2T4N2tNPJrSK)O-ZbPcc{#{m
zwFV1_Z6beyl#6`jq;&Dw{r$9<LFotFgd<3LN?>2h|Kvj6RP$GCMf>BVC#1CJAM)Gj
zvmN^o9EV?!+K|Rl4z=-qCLioN@&BPC^9~L4B%AZBVb9d4`^P`9koZ!pPikasW?3J7
zZ`a6YwQ@hoX-^Si6)c_>tCw2l4i#gd$wWUVjU|7^TC}6V63QQ-z9l_-NChZoB0i7&
zi&&WSm{<}i?fLJyykZ|we$it3GuV&(F_n3L>Q75`)U%o3yQCOZ^3)^^BF*L{SMVnB
z)1-GOr#&sKZF^!nNO>rCNUKEsM6M;)m{j?>cz5#tX*z78;&svo1adz&@M3HHD|LfO
zddguZ+GHl_r>q%9keU<EPq_v8N#uv&LOe;TPhL+C^1WTw`<NI%nZCE1Kv$C5%%*{!
z)f85dPe(dQd5|i3YLRl2Dp|QDR<}N?|B?KQSeE!iEJyl<^eIWtOwvGWUochnf3ywO
z&U~3hx7CnxZTy{vH>?vyFOYa$%Gs@bPkf8CfmlgWH`<KBL&PRxbsVRTJXa|Xby;sQ
z`F=i?`Xj7opTnC^tO_ZN25%ADg;Pm=h+VffM<}O0SdsV?D{nFL5F2AnR*=79@s8vt
zk*ZmppFl->3Z)1ZB<XobtS#wB@*k3Jl8RD3fX_c$D12yrYGWhHRY`x+R!>Geg<Dfg
z?jLR78{&CM{6+G;yJV)?Am3BD#qxE@uUBJIR#GQYVN%+&j?6jw>e)^Tpx^4$lKV$`
zt5azRd7re9{8p?*+acs1==)ow7CbdbnXJ=j%tE80#Fmp@w)l8r`$>B#ziF}Vl=U1o
z4`4ycM{&5d>5QEz|3LgK>06SX@|x8=Qcem-NmWStSCK?wdq{KW^bTb`LF6wIdkLR^
z5-7YvT+eJ$Ve*%){0#FEi^rX$cI4kDg_Cbh^6T)Ay$qNg^^_;y5kp9)$bU@yZ>w8R
z`3&iOQrdHmOg+lAX>*p;)Y_gV-pTT6Tb%rz=gJB6`7Ry%-&nQw<f6d>YxIQJXQT`?
z`~zRd3)J->e+ZY7niI=QSx+4KPl-JxpPu~l&u+^53GAl+6|3k?{w1A%Uov`*kgkz(
zQ+}ub&%fkP*kC&-r;sL)jt~nbT_*n>DW0^I@_Af_O-Xuoq5i#W4QU&xB}va$4&8tE
z+%g~V!l86}OnDz3w8?#Ls@ZblHA&;GPcr$mrz`CaQW#Dw1Nn8NQ>2lkS4nz`JG_dN
z{ST~jHVSLV=df}se8KY8-g}cwF&iYs>huG)lT?TFZ(2iKLc3UNGY-oTD~<Ju|3$td
z4oJ`XTT_UoPzeJ_VKf>~ITEu|4k52+2B{kPKS;lk@{_ug`jbYGCemI{1o=fIJwM?`
z#G6RUqsW)Db|0s<VE?O7Ie}mn0(CLYI^U%{oHT;emiT<!h-a)mFZpTYcM;dqoV18o
zC1Qc(4_mvX#Qvk4k<^;}b>inyPrSBI&o+XkT{iWfEBR-B>RMQ=D)ni1<z3=MNF9hx
zC#5|j$($qok60+Fo>jb!4QT)T(~J5$1pHU1$Y+E6@A($wUnf<gz7U4vCE8^opBZ1K
zJdpe|QWxvLjo6=*^~@yJlk~pTm3Lup0_9QU&%3zq4Ip!zjzz4&0Ng?B5QdUUQui6<
zS>*d*6Utwb*HegcAW6?ghnJK3>6VYc8q_CZSyCD5o_`)`Prs$XG%6}u%_rD|G@5jh
zcv<R)kw1WMk=m2mQZ7dNf|T~WVS_icz)H$pNwJi>(WfR!Pk?z}>P#<^zNgS1E0B7T
z%20lj&c{f}l%pu`#J;4}<bNimJx|FWq}^OnGlGq*ZY1R%Bt6p{UJ7;LmTy4bf5{q0
z;zm+8I)sp-701n_8l?9~Nra|j3F_WPJ<G@^lD|Rz7xl3x3|CTaW9=0Fn$(%pkk~NS
z*7sJZh9}AzW_zx~3&dB@a6ah`(jd}cVnL{<B@UtetE2^#^Whj$7sXOr@Rg9)bHU+N
zpj?vtDEh8VUy4tLz$5}g3GA`P?~z|+`FrHwqrPEEks1Rtr7W-eXP%T*-A2TwL`{t-
LkWznLg_!>XO01q<

delta 15590
zcmY+~2YgT0|Htu5BoZ>nkRXvrf*7$!ViT%n6s_8$jV(r`R@*D~Dkb)wwMS86meSIp
zC`yYeRYhp4KU%cf|LdJ|@_6{)$K!Z@KIfi$&bjBD`=!6fZ^3wvzs7sGKLqBR?{J;T
z>p0;!uZZKE^l%*Cc-1;iTwTWrz_RFtiRg<B(H~o*A9h8x8-Niw28-b$TfZB_$d6+<
zUd6(W<2p|Xf~Y81FQ>wZL?7}hSP1K&9&CmAF$Fb&!RU!&Q0>Q|CNLc}u_YLan~;H=
zW48Xh^(Oi-zVnbk5B`mfuwZ@1DT=MFeX%lm25M1uAx)hhZGDLb=6*6(qJA9e{!Y|H
ze#HX#9KF%Ip|LOqx>OV+D2VZ>H%>$?X$#a+y>3lMz3E)kgqI;Z<!nXO??erF00Z%J
z)SF*Lz0j{%0=;=lwJ(FNf+_@B$~vf-Ho_=ug?eDHt)GeNaG|Z=gnI52YQkTmUgWCv
z4)!5`gxaz$jZM3LsENMSnDy6<NmS^;xu}`0K<#-JM&miu{YR+wPLi33A8L;yQD>+Y
zYKdE-4r>SWz%Hm4?P2YYapY-9tba9vWmLrASuBr_Py<CZF~(bKqn5TAs>9Cs626JG
zaSK+&+xC7)Q!}AR45mH~wZ-*NE7ry(&}Y&WtK&%25^qJl$r044KZ#m_GuA7p3H)O3
zKSZ60r>Ks7n3eKS)Qglstx$FA%c%a{t_0eP6zd37#}lmYp|)ZnY9c!^1hY^R|JvTa
zi^a+FHe)-m1nPc$)E0F@ep;MisOP65{kqNy6FB=&51vJB#qXFOgI_iegrk<QDrzN~
zp*n1D>j$7#&b9aFpeD2&wG#VLEAY9^uVIM3{|5v*<sQw=K+&i@PC%_pOPhB_buieP
zfqJv~r~$U2-s~{y5T8f2e}G!SJS|MSVyL_v7G-=Vi9m1CO%*u8HkgH4felyycc9+z
z0IK7!P>1P9)bkHfZ|vRDoPk)(Suxar%~0)nqF!tyy50nn3G~LZF#^}14<1EL;0x4%
z-(WtxjT$)H`VW>T_iJTLM6E;$df`~q#K)snY99LHx>l^eX7ZuEaTL|@S=3B#px)pK
zszbllW~*Y*m%K4*;>kAejT&f(t<OL`HxIQkn^FCJhMLHg)~vrC$fiOAKS$;MZOq<A
zp&m%Ucx;Uta4f3bEYulTWzE8Z<Y!T*|EjJ3%Nmeu>dT;3xUoy1C2ocO*a7444b(5+
zOpL`HSP?JS+@q~&ABUPiE!3MPqqZmowdVsc04JcfIureHnbqA)pf}1wE$Nr`#%0um
zZrl5hQD?yW74w%;IBI1Qu@JUF-S1)ZG}MG9q9(A!-rtNm<VTQkUFR}^W|WO;_!Kp;
zPdjt!!%!W?AP+c+sEM{hEpaFG#1S?hgIdw~sD76tzfH~-tcic1&Q{s>`n_TQ8xmBY
zq9<za-b4KWEk>Q{!&nOMVj>1~FsHgXYKhyT2I`N6aU^OBrlI<qg_`gZ)QfFEeHDAr
zN8kTpTX7P#M_;1{UPAr-e+47)32KGHUN!CFtW{7yV6{=7Zzt558Hpjd41;l(tv`vH
z(0z0@^QQzd|7)gU2x^JSp*n74?TLZpE@}_wViDYmIs?aT{RPya{1vq#ULDQE!cYU(
z#QK=jk@YV_Fy1!UjLJ`;X8b#92|ZsoOI8@QB4HScHBb|8jXDEeP)pnkHNl~%6`gMD
zGf@**gIdWQue1I^1RqnO8J$P%U0^3uABvhtdDN0N#1KrucpQrwU=!vnF={1Fq9%43
zHL=^MJ-?6Y|9`0O|A}jZyq(RS`=Vx82*WS})lp3>f=z7R1@(*8AN4yi2{rIWEP(s5
zD4sy|^8;$&zfk@EgIWRCr;AzQqNp3CtW{ARHbBj^Eovg2QE%K2bry!9I+}=D`gy2+
z)}r=)r@j9P<|Y3IwNe+6=UwL>fjWGS+6u3(X2!)(dtVK;=M7K;c15)tVx3^?7oy&D
zGwN&{$C7vjwQ|o<6AbQVCJ=)~^!+Cg=)q*vXVwq(=2KC7y%^Qu4vfH)w*F^S$A6>l
z2X;3TD~DR@`l!86LiOL=+8gVWkHx%<?;Ie|3LHia{2A&O>=LSj>!>~b8MPw6TmP~3
z-aSlx5NazTQCm<6wFPzU{Whq6I-}a9psR-Iw!wJRDV>FtaXyCQXQ)H=6RN#ePqUOk
zs4a;`by&mN6ibu8iW=~3)N@l%FEG!#sweBO8E&OQpVJZ4(%r|Z_z<-svAyg7s6*Hk
z)nR+o;p~YTXaH&=>Gu94>s)((C2Gqyqxw14%Qbs<h6>I27t}=jdz-zkizUcAV_6)H
zQMeXM;t5now^5(lBh-Zb-!O+P5{r>n$5NP#H8343;ue=cZ}2^8fLo|j>i?!Wj1gFl
zydG+T{V*11V<hfD4R`@{R(`hi*{CyAFvZjdp_aZVdSNNlb8ZD&Q4c+-Xo7mM1?n?v
zhkCFNYGT7tZ<K*r%4MkUc{gh5Phm7(Ms3mGsKf2u$GkvctW2JO{M5Tne}W2B%t1YP
z1ogSxKrQJrtcAgS&0%bXTJrv=vojL4Mbl6#vJo}lDb&iIL$$kt+Oh{W59p^CWB*GM
zXrOr1jGJK$c0ir}QRt2HQA@VM<~yue=t2E4%#WX<`Z<kSx$jZG54TY(`Y&oif&KY?
zVSFc+Ko8W$I@k=$;{?oyJJ1XFptj(k&A&iR{2Z#oY}60dbJSJ@4KV+48-rT0j#wO1
zQ0+3%Ek&@9pe7!+Hy)uH78uCC=dd(t=95taE<t_wd$1s$M4f^27=d?e?ls8d#ZePV
zL`}50waXyZzZw;TsnDCQM$PmC)P(k+CX$WX<NSloS5g|)VO!L|Jy9K}V=PWV9n!5>
zA5Wq;dZn870a%GVHkI|)l6Ih?0KQ>OML+V<s6Cy6O>j19pbMx&dK)#t|4<VyKE!Ng
zJJbYwpq6?F>g-HHe_Vj-cfCuXfwrM99>f4Vg__W1)ZYJuI+XWNZ|FbNe6H0{&nKc*
zq8aMBuBa{Qi-mBE&1YaF`3lt5xkm{2(R02)&8)&O^Ig}$0P=RI`rfDs4nr;NyY~KK
ztVF&E^?7}Vn&{uC_L0NQ{R*g+>WVr`qmYTa&P)O|T!Mvh18T4Kn+DD&*0ZRk`~mf*
zk5C=?q?rjuU?6z~n>WDH<ZV!gG99&2nW#g&8jI`uKS<z1#U<3hH&6}lVH`d|y-~>#
z<}anDm`FYfE8s5F*|?56q_OE{;3U-V#{lGubf%yt@(?wFfVcQzV|=G9K|J<CeU7uS
zJbr}yed64}=GbJU+4EVb3C}}Kcm)RI2Gm3kq0YdMSO~LG?fyZnu;(b#Pbj*YVFZCX
zsEt~}WDLRHHXnt0a0aTw<*23IjylB`F&|z-ZRJf=``=J!CC_LxU^r@G(WnW<k7oUY
z2x?NHj#^mTp$=7Nn@>l**<92i+lwA}8T;hnKeJJXHEFCFs6A>Tolq~-2laxZF$`y6
zQQR<=_16rK*&A1}9Qk7`i&1Zzm1u!Q$a|tX7>Szr`_^@+vyz2+ksGKLyo;XrAL`9L
z-!Usw2z5W&CD0ospgQb~`rJ}&gLhC5W}=pK1t#N8ERN2*W<ueZL|OxN2uEW}T!aaD
z3mahsFI)$EU>kIo66i49Lk;xE=KtB;XPlW>Flx`@P)k|`wIWSWFVMyM4yvD(s3qQv
z`k^_2dV#YTi8qlIbDg{yW(&%rmi#5Gf<sXgTZ8IwD{9a8+4_U1U&2qZB;G@{^Br#n
z3`MO_4C=WCsQXE%v(hFf=PkPv^rE6a>d<_Nn(^hF1}vTRH`Iilp*qet!Th}*g<A3i
z)CzS(4b&60l><?4JO;JrOVA5<Vm`)q_SuR<s0kdmp2b?^*Dw)7CYs-cWUNj;7WG55
z6V>q%>jl&n-9t_IIqEFrpTvK_VtcHH^DyV%|ECDl@FHqYucP+rPgF<Vlg%$#anui0
z0_uJr^ukf7=UvnbOhc{U0`$T)=!u(96W)P({_te>UlnJl(2`w4?adwA@Cka6drdJ9
z_@Ul35>;OV^;}&njm=O4r=qrG4C;jz+k7WRke|U)_+Sd_uZBTW&A;obqCUg+s6Fe8
zx<3V@@dMNfd~Wamh55-tr<pg4M6E!)wKnSar5S1>y)oy92=$_~T!JWq752u*sD>9%
zOP`H;<9{(6gQwfi57j{v)K78;Tc3g&a1?5wOw<q7MpVDYP%Cg5^<wUywn4}YGeAYu
zz%5Y&cgIj1Ve^@&j@F<iv>(0kThyChM?HTZwUYl?gJznEl|c>M7-{c1y$IA{D(bVE
zWL=H=d=8@y&sR482KDAQP-oyF7Q`p0H_rE-X<rm|$Rbe_se+ZTDaK<u1~b01mOyWi
zh3fbi>dnugFaCm>$RpI7dCW2$7eRF#hkAj!*7m3+9)SKh71iHD)K+anP4tNB_5GhG
z&`j@Q8T=cyXEC$Q19edoYmI8x%{l}%p$t^}c~}v*p$58Y?>|5d=)7;XEClspRnb)k
z4G8?P9cn3ip#~a^KIo!O`7~6!IjFB;6~^Fk)EobTmC$pJd661eh`bwWf~ly(IRy*g
znmMe$X10xr!uSblDKDT7T{h}LkGW<E!%-bpMJ;`jwIizCK<h-*=erzrNDrg>`yRCw
ze_$YbXR`i!uz02!xH{^=mRJ@CqW%_}k9uGS=1dfW$S<HezJq$snP=KZp;oFkY68ts
zhq#Z;2cgc~G?zecG#BgRTC9#YQA-;--*i|9RsSlg{dA1NrC1ja+51niGI_-XW-EK3
zCO8oNZ~_M79Mpu|4+ym9TQLB$P;Yh$bx5wFW_lNO2L7-<Lw&A!7n(OOfjSfQQ3Liu
zot@FB=O&<jqGw<n?!p9p|5pffc!C$1rK*Z0$&*n_I1Dwxai}+#iJIUN48iRfiziSk
zb{C7F-(q7dhLYDotz3K53cQ(9&;E}l(4Njg?crL~z{jyEeuFv-VN1+J8)H@So~VH`
zQ4`vXI@L!|pYi8d6d$5~s0uDM_XANc9D&h{@6;jC1iGWPU?^&a<4}igK5BrCSO*Vb
zK74}3@VPZ?nc0GBsENIXRd6tB3s<66Y$IwVj$qFJ{(pu*dv(qF3+jP~sEIv8P0V+>
z`KMGk>P@3i1C_UVV=P79+SaFG6!~=2S=xenu`E=7r<b$;;RM&H(9E8p-mK6HvveV-
znN~s#*bsFVTB5e7qrKk;)!|6%6jb|#HeZ7}JG)RXbQIO^H!E0w4g8aB@Dw$H{434r
z_C-xF4)q32QE%K1Bd|Ye;3*h|3sD1P+58LC8OTOG=ef%K?H7*49k$aY(CO`nYWOy4
zi58%~f-R^APoo~Vh5F2#)uw$6hLP4oP4qR?=aq(fv1O<;vI|S#Y1BCPG3WdDT4TOX
zUsOlIsE*>X1Gd1txDs_H)}SV|#pa)&mikN75`T*t_!g?&Gt^cUUTan=%32v|?>Y?$
zG;kYxqc7@#k*GbIjOutHYH8P_wqhS9;0Y{;&rxq4x6T~qS5RL~7u0h@tQn~OGqEz`
zJ8KAZ`p=`@<eF`82Q|=NsLwFpdh<IFiuuSJVG_QCTAAsn31^}vyw17{)$dVz|4Unc
z75y3CxksRZo}x~-*9YboD-bo)ayC!IO60F#70kdG+>NF264t<fQ9oGKH<<r{qC4u4
z&On`krKqhrfUbTT&k@MGs3pv^(fltO<uH+a2x=mmP!q^Pb$AEW;S<!rp_|NqptQzD
z<fE}99z-4LYv_r8U^G72#QJM#if=ZDurzAVDxwcIMy*UM^u|u84tiq&?1wttX{h$I
zP%F3yHNkbLiSI$3f#azDE~37so10nx`~?3};e}pX%x}A&wINofKFzug^~M*ik5Gp<
zXsh}Ed{w|$@&Tv`%toD^m8jo~UDmUx3IFO6=*^y>-Xza9^Aj45T8YM}H+T)>F$MJ|
znHY)du@rua>M$GieLqG$7qH#*UjjYI%b+ioNA>R}5ELM2g*v?*Q8Vv{n)xVH!x^Xt
zSE5e+R*b{nQ3Ds<VYVg){mILs`b|K!OG5QC2=)6g&geP|2x?KW6$9`VY9$_719qAv
zEr)v3WYiX<qYlwL?1TGI9hdmf+^>#0)a_CI4n&>$u~-e~Vo`nnpAo3zA5k56?J^C+
zQJ+^eY=9k6Z@Lh*605N#Zb$9yGt_Ah+igD2+Nk~pp#~n0IxCA&hi?}~GQRUQfquz;
zML#UF$LwJZ>`I=Dg>frt0!J_yPoXAq&DP(;w&agc12_H14Acd+Vk5C8E<l~R)97li
zz9rBS{fx>VqXx{k*L)pCP!lU*t$=~#wXq<!Mtwb<unhLJPDM>{C#v7Ws5k!#_1vAk
ztbYi>BPxQhz&>LM)L%kXQ5_^>E9{Dz`6hdR7wY*lsL%LkRL2ichwdqAqS0AQ7HeQR
z9E#f7rCF@MX7(`^I@Moe6b9}$KdIHQ5P4_R3Jk<x9F02NvrsFy*t!Wdq5W6{&!ATF
zHmaW|s0sNTF!kXsK~*X$qB`nj9fncl8CVwApa%R3^`@6lD{~$7M)y&NIP{=7-Sx08
z`7o@E+p#fT$Kn`$$gGfCpFoGBFHXeCsMB2VusO|Tu?cxg)RMiAn$RAM#~*M6`X1pw
zPA~&K@e*ofuc5Z=K5F2{7>0#E&RGf9DQ5zw9_n<rMJ-)tn-4?1*;v$rtL^;*Sd{!6
zYRR)vOPlAY*`i?7M9ZR9u03iZ-BA<ki=q1d-y+a}vrvDpuSYHIX4HiCVJIF$b?_Z(
z3vQwYxM%a=W9DzaaO=yMm-{0y59(4s(Nvzz$@qiu9X|V<>zQqJ4K<;0G%QH`#MZCI
z;}l;?Ece!{cdm7mh6=bU*tY$+=Mra77Ezx;X-VB$%CFR|CNHTr=&*%O5h|xsv7Goa
z@r&2{B+aNB&;2@-uH+jjQ4}xo2Dbh0<huSa<op@OeO<-ysBKr6c$JA==Lt7GD3NN#
z^?(}-a7IqX^5SXR$sk<J{Ta5SuWWrHuA^=~?z8u%5x+rc#q;MW<%r|C_Yv_F^dNsL
zPY?dflwdrSx@K}?0eS9qn!GjT#cL+XCF<U!-BIEY;_q#P_o>@J{0XHvaYE*eia~A`
zEvAy)!i_kbqN_aivGLEQ%9&ujgRjv36Ux7o-0L6WZ9KCL-=uOReoJXjd5b*2_II0D
zKZRVbbBsT?(?Hh->nz)_m#NPA^Kbslq<$ebp|r4VrrBroz5PI3$maTcH}@({T@72G
zTbGoRbH>Wjk&fC0%1GibY>QWEu#o&y)VHK-7bS{3KlSesH^)+xzp0x?$-VMW_u}<A
z_g=R5U*Sd{;$zA+us-BE6kVIB+)F8`n_TrM11K}-@H@Ou{VB?Ja&J3GvTgf1`3_1r
z`RlaJy_yoNp{@m`R&FJ7z8v=y4{YSdM9K*&!fmH&H(!5n-R0guims~Ig*F8#`rD?B
zq_m;F1bJKHw~2@1Ts%Q(O026V@f#*|zMw8l@Bbkc-6(1^od&vAlB^)kOF2$H&~{Lt
zxELkD=C5E~`wZ32&%`fdRqDrJ70NG^#uQysDgABx=p0%9blcf$)>IljpbQ{yfDdVS
z&pt%z_)y=NJjAx|jms$OsVh(EPMdT*Ox+l)ha>eM*A?=?CUoW#_jS2h*jDVd&Ze$5
zC4dG?srwKoP*SM7Y1@2Et}74LqJF&1H(Mj9OE*oNrNrOc`p(4fP-@$IZZ&RnB#Eao
zhN9~)>N-$v5r0a_rj#M)9|-^N+C=u5eWnpMCr_k2rmZed{0ukeWaf|daGm-HN_p-7
z?*tQVCpXkMw?pE!lpiSi*GFec3?=tkL+~}v>e@<KPP`%~Gk<v4d&&k8kE6^b-hvGo
zV-WFUeSev1!Bv;yWgi-WzBDRL-C|0rtshNY7G)3l99!3uT-Oon0W3*=6o=Y2U9c<p
zPt<=&xlYklU4IY0M+qVMn36!zKSkc5ZZ~Bn4{amYRe<<Bbsh1=t18J@>UB-0#1emJ
zbBDGO)Kw<mNqLp{Af+&IJBr(gKlacl7<JVk?u5RSlf++8|J2@FNB$+{5GD6|NYI45
zA#J{*w6bkaQs3FeYFn20w-@s2JaZ!t>)%3m?Ny8hvu&g2)SaVv((r#c3D0t`C-Gri
zNJ*wHoLpBW;&asfOPr7R#cLP&J}P!`f2_Un2Jvg!{}cjUM<~}Q#mWCv1=n-p<F>Qy
z<a;QiDIZhkN4ZS=Bc&2$3;EZ$7+X_xeTd$;lCp);mZIyDLFeCG_pN_2U@8wiBj1My
z?c^?6)ocm%4Jf1RGaHC=uWqzEKr)QFe8g)hCn>`z?^1Nd8JwEr?x*(QK$2C&MQq*<
zU$(Jr@66G|wv&(Sy$sw*X-xSiw;?W|U1{596jr3JA~vD^FXB$vKQHg!j-(VxP4uD^
zqR}YwFbpF1Bi1#AQiu2vC7TjS=|SmB8AcgPdtE`qnG{{O@pI~1;wkbG#MNxO&vRO^
z{)yZiLuCOflCYwE_&4&Qlwp)tsh@=(;FtD(3F1k_JE_;zhBA-3TGZtyK4RM~qRt`C
z2knV}B0i%!t}6Qd*R_?(3MS0?a~Xd|aWC1{)#m;<8}FsQ4CQs|CR1{+;RL5CSE=)-
zB-tCQ@Fm)R_Cnj<#J^JSzRisi_Qn<aKrRyr>T*9C1MvdwJc$e75c2-S|53Wy=eJV#
zh+NlH>UvQQ*?ZMYwKJMLjrfeIcb$F&pYUKQ+n^urpzaX*Q7Uln8}ezyZ(=L*Z;5rq
zkb6^ftv5KK+@EaYK&;38cd)Vw_g=i7>iheV29vo_!`}G{TT(_)PEcQk`$LEi;8IFQ
zN(b^Xl#7(yYmV*wC0ntaygMb9ygSd-r|8OW{h!qL{|)6PNnfl^=}oCbK9`4&Q9d9K
zBj153l$FH4P;#&Th!4{4JxXgTo7;P7<h>}mCK;TMxL3%=O^MytY~xV;fYO5p{3%6=
z^Wr8-eae2yJRY2a<+!&RbuA)(m-sI69pc=}pI{kz2is2dS1Da6&8Qn<+PcmX-Qg-~
z8wS1bKr`x>&~O&zeaZmJK<a!@S6duRdwoT-$s_SCN;k^8IW731A=Y)q;M629N1Vp9
zYx0)oH=ByLsTfSfZrgZ2@d_LNL7c_?mv(#B?dR!}GH6I@-?XR>Lv{~r^tia+@RYRl
zK_k+l`V2`=eRFr$ZX;p~G)w7seDClPQDf3~pUDV{3~D;OZ%Xgf{%L6`QGHYT&@3f&
K_mx$3qW%va6uqVZ

diff --git a/InvenTree/locale/de/LC_MESSAGES/django.po b/InvenTree/locale/de/LC_MESSAGES/django.po
index 95c8b95fb6..493d4a8b06 100644
--- a/InvenTree/locale/de/LC_MESSAGES/django.po
+++ b/InvenTree/locale/de/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-09-28 12:03+0000\n"
+"POT-Creation-Date: 2020-10-04 14:02+0000\n"
 "PO-Revision-Date: 2020-05-03 11:32+0200\n"
 "Last-Translator: Christian Schlüter <chschlue@gmail.com>\n"
 "Language-Team: C <kde-i18n-doc@kde.org>\n"
@@ -92,7 +92,7 @@ msgstr "Datei zum Anhängen auswählen"
 msgid "File comment"
 msgstr "Datei-Kommentar"
 
-#: InvenTree/models.py:68 templates/js/stock.html:690
+#: InvenTree/models.py:68 templates/js/stock.html:699
 msgid "User"
 msgstr "Benutzer"
 
@@ -107,19 +107,19 @@ msgstr "Name"
 msgid "Description (optional)"
 msgstr "Firmenbeschreibung"
 
-#: InvenTree/settings.py:341
+#: InvenTree/settings.py:342
 msgid "English"
 msgstr "Englisch"
 
-#: InvenTree/settings.py:342
+#: InvenTree/settings.py:343
 msgid "German"
 msgstr "Deutsch"
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:344
 msgid "French"
 msgstr "Französisch"
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:345
 msgid "Polish"
 msgstr "Polnisch"
 
@@ -151,7 +151,8 @@ msgstr "Verloren"
 msgid "Returned"
 msgstr "Zurückgegeben"
 
-#: InvenTree/status_codes.py:136 order/templates/order/sales_order_base.html:98
+#: InvenTree/status_codes.py:136
+#: order/templates/order/sales_order_base.html:103
 msgid "Shipped"
 msgstr "Versendet"
 
@@ -206,7 +207,7 @@ msgstr "Überschuss darf 100% nicht überschreiten"
 msgid "Overage must be an integer value or a percentage"
 msgstr "Überschuss muss eine Ganzzahl oder ein Prozentwert sein"
 
-#: InvenTree/views.py:639
+#: InvenTree/views.py:661
 msgid "Database Statistics"
 msgstr "Datenbankstatistiken"
 
@@ -266,7 +267,7 @@ msgstr "Standort-Details"
 msgid "Serial numbers"
 msgstr "Seriennummer"
 
-#: build/forms.py:64 stock/forms.py:107
+#: build/forms.py:64 stock/forms.py:111
 msgid "Enter unique serial numbers (or leave blank)"
 msgstr "Eindeutige Seriennummern eingeben (oder leer lassen)"
 
@@ -280,7 +281,7 @@ msgstr "Bau-Fertigstellung bestätigen"
 msgid "Build quantity must be integer value for trackable parts"
 msgstr "Überschuss muss eine Ganzzahl oder ein Prozentwert sein"
 
-#: build/models.py:73 build/templates/build/build_base.html:65
+#: build/models.py:73 build/templates/build/build_base.html:70
 msgid "Build Title"
 msgstr "Bau-Titel"
 
@@ -288,7 +289,7 @@ msgstr "Bau-Titel"
 msgid "Brief description of the build"
 msgstr "Kurze Beschreibung des Baus"
 
-#: build/models.py:84 build/templates/build/build_base.html:86
+#: build/models.py:84 build/templates/build/build_base.html:91
 msgid "Parent Build"
 msgstr "Eltern-Bau"
 
@@ -298,18 +299,17 @@ msgstr "Eltern-Bau, dem dieser Bau zugewiesen ist"
 
 #: build/models.py:90 build/templates/build/allocate.html:329
 #: build/templates/build/auto_allocate.html:19
-#: build/templates/build/build_base.html:70
+#: build/templates/build/build_base.html:75
 #: build/templates/build/detail.html:22 order/models.py:501
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:147
-#: order/templates/order/receive_parts.html:19 part/models.py:241
+#: order/templates/order/receive_parts.html:19 part/models.py:293
 #: part/templates/part/part_app_base.html:7
-#: part/templates/part/set_category.html:13
-#: stock/templates/stock/item_installed.html:60
-#: templates/InvenTree/search.html:123 templates/js/barcode.html:336
-#: templates/js/bom.html:124 templates/js/build.html:47
-#: templates/js/company.html:137 templates/js/part.html:223
-#: templates/js/stock.html:421
+#: part/templates/part/set_category.html:13 templates/InvenTree/search.html:133
+#: templates/js/barcode.html:336 templates/js/bom.html:124
+#: templates/js/build.html:47 templates/js/company.html:137
+#: templates/js/part.html:184 templates/js/part.html:289
+#: templates/js/stock.html:421 templates/js/stock.html:977
 msgid "Part"
 msgstr "Teil"
 
@@ -345,7 +345,7 @@ msgstr "Bau-Anzahl"
 msgid "Number of parts to build"
 msgstr "Anzahl der zu bauenden Teile"
 
-#: build/models.py:128 part/templates/part/part_base.html:142
+#: build/models.py:128 part/templates/part/part_base.html:145
 msgid "Build Status"
 msgstr "Bau-Status"
 
@@ -353,7 +353,7 @@ msgstr "Bau-Status"
 msgid "Build status code"
 msgstr "Bau-Statuscode"
 
-#: build/models.py:136 stock/models.py:371
+#: build/models.py:136 stock/models.py:387
 msgid "Batch Code"
 msgstr "Losnummer"
 
@@ -364,12 +364,12 @@ msgstr "Chargennummer für diese Bau-Ausgabe"
 #: build/models.py:155 build/templates/build/detail.html:55
 #: company/templates/company/supplier_part_base.html:60
 #: company/templates/company/supplier_part_detail.html:24
-#: part/templates/part/detail.html:80 part/templates/part/part_base.html:89
-#: stock/models.py:365 stock/templates/stock/item_base.html:232
+#: part/templates/part/detail.html:80 part/templates/part/part_base.html:92
+#: stock/models.py:381 stock/templates/stock/item_base.html:244
 msgid "External Link"
 msgstr "Externer Link"
 
-#: build/models.py:156 stock/models.py:367
+#: build/models.py:156 stock/models.py:383
 msgid "Link to external URL"
 msgstr "Link zu einer externen URL"
 
@@ -377,10 +377,10 @@ msgstr "Link zu einer externen URL"
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:15
 #: order/templates/order/purchase_order_detail.html:202
 #: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:67
-#: stock/forms.py:281 stock/forms.py:309 stock/models.py:433
-#: stock/models.py:1353 stock/templates/stock/tabs.html:26
-#: templates/js/barcode.html:391 templates/js/bom.html:219
-#: templates/js/stock.html:116 templates/js/stock.html:534
+#: stock/forms.py:306 stock/forms.py:338 stock/forms.py:366 stock/models.py:453
+#: stock/models.py:1404 stock/templates/stock/tabs.html:26
+#: templates/js/barcode.html:391 templates/js/bom.html:223
+#: templates/js/stock.html:116 templates/js/stock.html:543
 msgid "Notes"
 msgstr "Notizen"
 
@@ -425,7 +425,7 @@ msgstr "Lagerobjekt-Anzahl dem Bau zuweisen"
 
 #: build/templates/build/allocate.html:17
 #: company/templates/company/detail_part.html:18 order/views.py:779
-#: part/templates/part/category.html:107
+#: part/templates/part/category.html:112
 msgid "Order Parts"
 msgstr "Teile bestellen"
 
@@ -441,24 +441,24 @@ msgstr "Automatisches Zuweisen"
 msgid "Unallocate"
 msgstr "Zuweisung aufheben"
 
-#: build/templates/build/allocate.html:87 templates/stock_table.html:8
+#: build/templates/build/allocate.html:87 templates/stock_table.html:9
 msgid "New Stock Item"
 msgstr "Neues Lagerobjekt"
 
-#: build/templates/build/allocate.html:88 stock/views.py:1327
+#: build/templates/build/allocate.html:88 stock/views.py:1428
 msgid "Create new Stock Item"
 msgstr "Neues Lagerobjekt hinzufügen"
 
 #: build/templates/build/allocate.html:170
 #: order/templates/order/sales_order_detail.html:68
-#: order/templates/order/sales_order_detail.html:150 stock/models.py:359
-#: stock/templates/stock/item_base.html:148
+#: order/templates/order/sales_order_detail.html:150 stock/models.py:375
+#: stock/templates/stock/item_base.html:156
 msgid "Serial Number"
 msgstr "Seriennummer"
 
 #: build/templates/build/allocate.html:172
 #: build/templates/build/auto_allocate.html:20
-#: build/templates/build/build_base.html:75
+#: build/templates/build/build_base.html:80
 #: build/templates/build/detail.html:27
 #: company/templates/company/supplier_part_pricing.html:71
 #: order/templates/order/order_wizard/select_parts.html:32
@@ -467,22 +467,22 @@ msgstr "Seriennummer"
 #: order/templates/order/sales_order_detail.html:152
 #: part/templates/part/allocation.html:16
 #: part/templates/part/allocation.html:49
-#: part/templates/part/sale_prices.html:80
+#: part/templates/part/sale_prices.html:80 stock/forms.py:297
 #: stock/templates/stock/item_base.html:26
 #: stock/templates/stock/item_base.html:32
-#: stock/templates/stock/item_base.html:154
+#: stock/templates/stock/item_base.html:162
 #: stock/templates/stock/stock_adjust.html:18 templates/js/barcode.html:338
 #: templates/js/bom.html:162 templates/js/build.html:58
-#: templates/js/stock.html:681
+#: templates/js/stock.html:690 templates/js/stock.html:905
 msgid "Quantity"
 msgstr "Anzahl"
 
 #: build/templates/build/allocate.html:186
-#: build/templates/build/auto_allocate.html:21 stock/forms.py:279
-#: stock/templates/stock/item_base.html:186
+#: build/templates/build/auto_allocate.html:21 stock/forms.py:336
+#: stock/templates/stock/item_base.html:198
 #: stock/templates/stock/stock_adjust.html:17
-#: templates/InvenTree/search.html:173 templates/js/barcode.html:337
-#: templates/js/stock.html:512
+#: templates/InvenTree/search.html:183 templates/js/barcode.html:337
+#: templates/js/stock.html:518
 msgid "Location"
 msgstr "Standort"
 
@@ -496,7 +496,7 @@ msgstr "Lagerobjekt-Standort bearbeiten"
 msgid "Delete stock allocation"
 msgstr "Zuweisung löschen"
 
-#: build/templates/build/allocate.html:238 templates/js/bom.html:330
+#: build/templates/build/allocate.html:238 templates/js/bom.html:334
 msgid "No BOM items found"
 msgstr "Keine BOM-Einträge gefunden"
 
@@ -505,12 +505,12 @@ msgstr "Keine BOM-Einträge gefunden"
 #: company/templates/company/supplier_part_detail.html:27
 #: order/templates/order/purchase_order_detail.html:159
 #: part/templates/part/detail.html:51 part/templates/part/set_category.html:14
-#: stock/templates/stock/item_installed.html:83
-#: templates/InvenTree/search.html:137 templates/js/bom.html:147
+#: templates/InvenTree/search.html:147 templates/js/bom.html:147
 #: templates/js/company.html:56 templates/js/order.html:159
 #: templates/js/order.html:234 templates/js/part.html:120
-#: templates/js/part.html:279 templates/js/part.html:460
-#: templates/js/stock.html:444 templates/js/stock.html:662
+#: templates/js/part.html:203 templates/js/part.html:345
+#: templates/js/part.html:526 templates/js/stock.html:444
+#: templates/js/stock.html:671
 msgid "Description"
 msgstr "Beschreibung"
 
@@ -520,8 +520,8 @@ msgstr "Beschreibung"
 msgid "Reference"
 msgstr "Referenz"
 
-#: build/templates/build/allocate.html:347 part/models.py:1348
-#: templates/js/part.html:464 templates/js/table_filters.html:121
+#: build/templates/build/allocate.html:347 part/models.py:1401
+#: templates/js/part.html:530 templates/js/table_filters.html:121
 msgid "Required"
 msgstr "benötigt"
 
@@ -573,7 +573,7 @@ msgstr "Lagerobjekt wurde zugewiesen"
 #: build/templates/build/build_base.html:8
 #: build/templates/build/build_base.html:34
 #: build/templates/build/complete.html:6
-#: stock/templates/stock/item_base.html:211 templates/js/build.html:39
+#: stock/templates/stock/item_base.html:223 templates/js/build.html:39
 #: templates/navbar.html:20
 msgid "Build"
 msgstr "Bau"
@@ -586,40 +586,51 @@ msgstr "Dieser Bau ist der Bestellung zugeordnet"
 msgid "This build is a child of Build"
 msgstr "Dieser Bau ist Kind von Bau"
 
-#: build/templates/build/build_base.html:61 build/templates/build/detail.html:9
+#: build/templates/build/build_base.html:39
+#: company/templates/company/company_base.html:27
+#: order/templates/order/order_base.html:28
+#: order/templates/order/sales_order_base.html:38
+#: part/templates/part/category.html:13 part/templates/part/part_base.html:32
+#: stock/templates/stock/item_base.html:69
+#: stock/templates/stock/location.html:12
+#, fuzzy
+#| msgid "Admin"
+msgid "Admin view"
+msgstr "Admin"
+
+#: build/templates/build/build_base.html:66 build/templates/build/detail.html:9
 msgid "Build Details"
 msgstr "Bau-Status"
 
-#: build/templates/build/build_base.html:80
+#: build/templates/build/build_base.html:85
 #: build/templates/build/detail.html:42
 #: order/templates/order/receive_parts.html:24
-#: stock/templates/stock/item_base.html:264
-#: stock/templates/stock/item_installed.html:111
-#: templates/InvenTree/search.html:165 templates/js/barcode.html:42
-#: templates/js/build.html:63 templates/js/order.html:164
-#: templates/js/order.html:239 templates/js/stock.html:499
+#: stock/templates/stock/item_base.html:276 templates/InvenTree/search.html:175
+#: templates/js/barcode.html:42 templates/js/build.html:63
+#: templates/js/order.html:164 templates/js/order.html:239
+#: templates/js/stock.html:505 templates/js/stock.html:913
 msgid "Status"
 msgstr "Status"
 
-#: build/templates/build/build_base.html:93 order/models.py:499
+#: build/templates/build/build_base.html:98 order/models.py:499
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
 #: order/templates/order/sales_order_ship.html:25
 #: part/templates/part/allocation.html:27
-#: stock/templates/stock/item_base.html:174 templates/js/order.html:213
+#: stock/templates/stock/item_base.html:186 templates/js/order.html:213
 msgid "Sales Order"
 msgstr "Bestellung"
 
-#: build/templates/build/build_base.html:99
+#: build/templates/build/build_base.html:104
 msgid "BOM Price"
 msgstr "Stücklistenpreis"
 
-#: build/templates/build/build_base.html:104
+#: build/templates/build/build_base.html:109
 msgid "BOM pricing is incomplete"
 msgstr "Stücklistenbepreisung ist unvollständig"
 
-#: build/templates/build/build_base.html:107
+#: build/templates/build/build_base.html:112
 msgid "No pricing information"
 msgstr "Keine Preisinformation"
 
@@ -676,15 +687,15 @@ msgid "Stock can be taken from any available location."
 msgstr "Bestand kann jedem verfügbaren Lagerort entnommen werden."
 
 #: build/templates/build/detail.html:48
-#: stock/templates/stock/item_base.html:204
-#: stock/templates/stock/item_installed.html:119 templates/js/stock.html:507
-#: templates/js/table_filters.html:34 templates/js/table_filters.html:100
+#: stock/templates/stock/item_base.html:216 templates/js/stock.html:513
+#: templates/js/stock.html:920 templates/js/table_filters.html:34
+#: templates/js/table_filters.html:100
 msgid "Batch"
 msgstr "Los"
 
 #: build/templates/build/detail.html:61
-#: order/templates/order/order_base.html:93
-#: order/templates/order/sales_order_base.html:92 templates/js/build.html:71
+#: order/templates/order/order_base.html:98
+#: order/templates/order/sales_order_base.html:97 templates/js/build.html:71
 msgid "Created"
 msgstr "Erstellt"
 
@@ -798,7 +809,7 @@ msgstr "Baufertigstellung bestätigen"
 msgid "Invalid location selected"
 msgstr "Ungültige Ortsauswahl"
 
-#: build/views.py:296 stock/views.py:1520
+#: build/views.py:296 stock/views.py:1621
 #, python-brace-format
 msgid "The following serial numbers already exist: ({sn})"
 msgstr "Die folgende Seriennummer existiert bereits: ({sn})"
@@ -913,7 +924,7 @@ msgstr "Beschreibung des Teils"
 msgid "Description of the company"
 msgstr "Firmenbeschreibung"
 
-#: company/models.py:91 company/templates/company/company_base.html:48
+#: company/models.py:91 company/templates/company/company_base.html:53
 #: templates/js/company.html:61
 msgid "Website"
 msgstr "Website"
@@ -922,7 +933,7 @@ msgstr "Website"
 msgid "Company website URL"
 msgstr "Firmenwebsite"
 
-#: company/models.py:94 company/templates/company/company_base.html:55
+#: company/models.py:94 company/templates/company/company_base.html:60
 msgid "Address"
 msgstr "Adresse"
 
@@ -940,7 +951,7 @@ msgstr "Kontakt-Tel."
 msgid "Contact phone number"
 msgstr "Kontakt-Tel."
 
-#: company/models.py:101 company/templates/company/company_base.html:69
+#: company/models.py:101 company/templates/company/company_base.html:74
 msgid "Email"
 msgstr "Email"
 
@@ -948,7 +959,7 @@ msgstr "Email"
 msgid "Contact email address"
 msgstr "Kontakt-Email"
 
-#: company/models.py:104 company/templates/company/company_base.html:76
+#: company/models.py:104 company/templates/company/company_base.html:81
 msgid "Contact"
 msgstr "Kontakt"
 
@@ -972,8 +983,8 @@ msgstr "Kaufen Sie Teile von dieser Firma?"
 msgid "Does this company manufacture parts?"
 msgstr "Produziert diese Firma Teile?"
 
-#: company/models.py:279 stock/models.py:319
-#: stock/templates/stock/item_base.html:140
+#: company/models.py:279 stock/models.py:335
+#: stock/templates/stock/item_base.html:148
 msgid "Base Part"
 msgstr "Basisteil"
 
@@ -1025,12 +1036,12 @@ msgstr "Zugewiesen"
 msgid "Company"
 msgstr "Firma"
 
-#: company/templates/company/company_base.html:42
+#: company/templates/company/company_base.html:47
 #: company/templates/company/detail.html:8
 msgid "Company Details"
 msgstr "Firmendetails"
 
-#: company/templates/company/company_base.html:62
+#: company/templates/company/company_base.html:67
 msgid "Phone"
 msgstr "Telefon"
 
@@ -1044,16 +1055,16 @@ msgstr "Hersteller"
 #: company/templates/company/detail.html:21
 #: company/templates/company/supplier_part_base.html:66
 #: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:74
+#: order/templates/order/order_base.html:79
 #: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
-#: stock/templates/stock/item_base.html:239 templates/js/company.html:48
+#: stock/templates/stock/item_base.html:251 templates/js/company.html:48
 #: templates/js/company.html:162 templates/js/order.html:146
 msgid "Supplier"
 msgstr "Zulieferer"
 
 #: company/templates/company/detail.html:26
-#: order/templates/order/sales_order_base.html:73 stock/models.py:354
-#: stock/models.py:355 stock/templates/stock/item_base.html:161
+#: order/templates/order/sales_order_base.html:78 stock/models.py:370
+#: stock/models.py:371 stock/templates/stock/item_base.html:169
 #: templates/js/company.html:40 templates/js/order.html:221
 msgid "Customer"
 msgstr "Kunde"
@@ -1069,18 +1080,18 @@ msgstr "Neues Zuliefererteil anlegen"
 
 #: company/templates/company/detail_part.html:13
 #: order/templates/order/purchase_order_detail.html:67
-#: part/templates/part/supplier.html:13 templates/js/stock.html:788
+#: part/templates/part/supplier.html:13 templates/js/stock.html:797
 msgid "New Supplier Part"
 msgstr "Neues Zulieferer-Teil"
 
 #: company/templates/company/detail_part.html:15
-#: part/templates/part/category.html:104 part/templates/part/supplier.html:15
-#: stock/templates/stock/item_installed.html:16 templates/stock_table.html:10
+#: part/templates/part/category.html:109 part/templates/part/supplier.html:15
+#: templates/stock_table.html:11
 msgid "Options"
 msgstr "Optionen"
 
 #: company/templates/company/detail_part.html:18
-#: part/templates/part/category.html:107
+#: part/templates/part/category.html:112
 #, fuzzy
 #| msgid "Order part"
 msgid "Order parts"
@@ -1097,7 +1108,7 @@ msgid "Delete Parts"
 msgstr "Teile löschen"
 
 #: company/templates/company/detail_part.html:43
-#: part/templates/part/category.html:102 templates/js/stock.html:782
+#: part/templates/part/category.html:107 templates/js/stock.html:791
 msgid "New Part"
 msgstr "Neues Teil"
 
@@ -1129,8 +1140,8 @@ msgstr "Zuliefererbestand"
 
 #: company/templates/company/detail_stock.html:35
 #: company/templates/company/supplier_part_stock.html:33
-#: part/templates/part/category.html:101 part/templates/part/category.html:108
-#: part/templates/part/stock.html:51 templates/stock_table.html:5
+#: part/templates/part/category.html:106 part/templates/part/category.html:113
+#: part/templates/part/stock.html:51 templates/stock_table.html:6
 msgid "Export"
 msgstr "Exportieren"
 
@@ -1187,8 +1198,8 @@ msgid "New Sales Order"
 msgstr "Neuer Auftrag"
 
 #: company/templates/company/supplier_part_base.html:6
-#: company/templates/company/supplier_part_base.html:19 stock/models.py:328
-#: stock/templates/stock/item_base.html:244 templates/js/company.html:178
+#: company/templates/company/supplier_part_base.html:19 stock/models.py:344
+#: stock/templates/stock/item_base.html:256 templates/js/company.html:178
 msgid "Supplier Part"
 msgstr "Zulieferer-Teil"
 
@@ -1240,7 +1251,7 @@ msgid "Pricing Information"
 msgstr "Preisinformationen ansehen"
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2108
+#: part/templates/part/sale_prices.html:13 part/views.py:2149
 msgid "Add Price Break"
 msgstr "Preisstaffel hinzufügen"
 
@@ -1252,7 +1263,7 @@ msgid "No price break information found"
 msgstr "Keine Firmeninformation gefunden"
 
 #: company/templates/company/supplier_part_pricing.html:76
-#: part/templates/part/sale_prices.html:85 templates/js/bom.html:203
+#: part/templates/part/sale_prices.html:85 templates/js/bom.html:207
 msgid "Price"
 msgstr "Preis"
 
@@ -1280,9 +1291,8 @@ msgstr "Bepreisung"
 
 #: company/templates/company/supplier_part_tabs.html:8
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
-#: stock/templates/stock/item_installed.html:91
-#: stock/templates/stock/location.html:12 templates/InvenTree/search.html:145
-#: templates/js/part.html:124 templates/js/part.html:306
+#: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
+#: templates/js/part.html:124 templates/js/part.html:372
 #: templates/js/stock.html:452 templates/navbar.html:19
 msgid "Stock"
 msgstr "Lagerbestand"
@@ -1292,9 +1302,10 @@ msgid "Orders"
 msgstr "Bestellungen"
 
 #: company/templates/company/tabs.html:9
-#: order/templates/order/receive_parts.html:14 part/models.py:242
-#: part/templates/part/cat_link.html:7 part/templates/part/category.html:83
-#: templates/navbar.html:18 templates/stats.html:8 templates/stats.html:17
+#: order/templates/order/receive_parts.html:14 part/models.py:294
+#: part/templates/part/cat_link.html:7 part/templates/part/category.html:88
+#: part/templates/part/category_tabs.html:6 templates/navbar.html:18
+#: templates/stats.html:8 templates/stats.html:17
 msgid "Parts"
 msgstr "Teile"
 
@@ -1363,7 +1374,7 @@ msgstr "Firma gelöscht"
 msgid "Edit Supplier Part"
 msgstr "Zuliefererteil bearbeiten"
 
-#: company/views.py:269 templates/js/stock.html:789
+#: company/views.py:269 templates/js/stock.html:798
 msgid "Create new Supplier Part"
 msgstr "Neues Zuliefererteil anlegen"
 
@@ -1371,17 +1382,17 @@ msgstr "Neues Zuliefererteil anlegen"
 msgid "Delete Supplier Part"
 msgstr "Zuliefererteil entfernen"
 
-#: company/views.py:404 part/views.py:2112
+#: company/views.py:404 part/views.py:2153
 #, fuzzy
 #| msgid "Add Price Break"
 msgid "Added new price break"
 msgstr "Preisstaffel hinzufügen"
 
-#: company/views.py:441 part/views.py:2157
+#: company/views.py:441 part/views.py:2198
 msgid "Edit Price Break"
 msgstr "Preisstaffel bearbeiten"
 
-#: company/views.py:456 part/views.py:2171
+#: company/views.py:456 part/views.py:2212
 msgid "Delete Price Break"
 msgstr "Preisstaffel löschen"
 
@@ -1422,11 +1433,11 @@ msgid "Mark order as complete"
 msgstr "Bestellung als vollständig markieren"
 
 #: order/forms.py:46 order/forms.py:57
-#: order/templates/order/sales_order_base.html:49
+#: order/templates/order/sales_order_base.html:54
 msgid "Cancel order"
 msgstr "Bestellung stornieren"
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:46
+#: order/forms.py:68 order/templates/order/sales_order_base.html:51
 msgid "Ship order"
 msgstr "Bestellung versenden"
 
@@ -1487,7 +1498,7 @@ msgid "Date order was completed"
 msgstr "Bestellung als vollständig markieren"
 
 #: order/models.py:185 order/models.py:259 part/views.py:1304
-#: stock/models.py:239 stock/models.py:754
+#: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr "Anzahl muss größer Null sein"
 
@@ -1525,7 +1536,7 @@ msgstr "Position - Notizen"
 
 #: order/models.py:466 order/templates/order/order_base.html:9
 #: order/templates/order/order_base.html:23
-#: stock/templates/stock/item_base.html:218 templates/js/order.html:138
+#: stock/templates/stock/item_base.html:230 templates/js/order.html:138
 msgid "Purchase Order"
 msgstr "Kaufvertrag"
 
@@ -1567,32 +1578,32 @@ msgstr "Zuordnungsanzahl eingeben"
 msgid "Are you sure you want to delete this attachment?"
 msgstr "Sind Sie sicher, dass Sie diesen Anhang löschen wollen?"
 
-#: order/templates/order/order_base.html:59
+#: order/templates/order/order_base.html:64
 msgid "Purchase Order Details"
 msgstr "Bestelldetails"
 
-#: order/templates/order/order_base.html:64
-#: order/templates/order/sales_order_base.html:63
+#: order/templates/order/order_base.html:69
+#: order/templates/order/sales_order_base.html:68
 msgid "Order Reference"
 msgstr "Bestellreferenz"
 
-#: order/templates/order/order_base.html:69
-#: order/templates/order/sales_order_base.html:68
+#: order/templates/order/order_base.html:74
+#: order/templates/order/sales_order_base.html:73
 msgid "Order Status"
 msgstr "Bestellstatus"
 
-#: order/templates/order/order_base.html:80 templates/js/order.html:153
+#: order/templates/order/order_base.html:85 templates/js/order.html:153
 msgid "Supplier Reference"
 msgstr "Zuliefererreferenz"
 
-#: order/templates/order/order_base.html:99
+#: order/templates/order/order_base.html:104
 msgid "Issued"
 msgstr "Aufgegeben"
 
-#: order/templates/order/order_base.html:106
+#: order/templates/order/order_base.html:111
 #: order/templates/order/purchase_order_detail.html:182
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:105
+#: order/templates/order/sales_order_base.html:110
 msgid "Received"
 msgstr "Empfangen"
 
@@ -1672,14 +1683,14 @@ msgstr "Bestellpositionen"
 
 #: order/templates/order/purchase_order_detail.html:38
 #: order/templates/order/purchase_order_detail.html:118
-#: part/templates/part/category.html:153 part/templates/part/category.html:194
-#: templates/js/stock.html:794
+#: part/templates/part/category.html:161 part/templates/part/category.html:202
+#: templates/js/stock.html:803
 msgid "New Location"
 msgstr "Neuer Standort"
 
 #: order/templates/order/purchase_order_detail.html:39
 #: order/templates/order/purchase_order_detail.html:119
-#: stock/templates/stock/location.html:16
+#: stock/templates/stock/location.html:21
 msgid "Create new stock location"
 msgstr "Neuen Lagerort anlegen"
 
@@ -1714,7 +1725,7 @@ msgid "Select parts to receive against this order"
 msgstr ""
 
 #: order/templates/order/receive_parts.html:21
-#: part/templates/part/part_base.html:132 templates/js/part.html:322
+#: part/templates/part/part_base.html:135 templates/js/part.html:388
 msgid "On Order"
 msgstr "bestellt"
 
@@ -1732,15 +1743,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr "Dieser Auftrag ist nicht vollständig zugeordnet"
 
-#: order/templates/order/sales_order_base.html:42
+#: order/templates/order/sales_order_base.html:47
 msgid "Packing List"
 msgstr "Packliste"
 
-#: order/templates/order/sales_order_base.html:58
+#: order/templates/order/sales_order_base.html:63
 msgid "Sales Order Details"
 msgstr "Auftragsdetails"
 
-#: order/templates/order/sales_order_base.html:79 templates/js/order.html:228
+#: order/templates/order/sales_order_base.html:84 templates/js/order.html:228
 msgid "Customer Reference"
 msgstr "Kundenreferenz"
 
@@ -1952,12 +1963,12 @@ msgstr "Zuordnung bearbeiten"
 msgid "Remove allocation"
 msgstr "Zuordnung entfernen"
 
-#: part/bom.py:138 part/templates/part/category.html:50
+#: part/bom.py:138 part/templates/part/category.html:55
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr "Standard-Lagerort"
 
-#: part/bom.py:139 part/templates/part/part_base.html:105
+#: part/bom.py:139 part/templates/part/part_base.html:108
 msgid "Available Stock"
 msgstr "Verfügbarer Lagerbestand"
 
@@ -1974,11 +1985,11 @@ msgstr "Fehler beim Lesen der Stückliste (ungültige Daten)"
 msgid "Error reading BOM file (incorrect row size)"
 msgstr "Fehler beim Lesen der Stückliste (ungültige Zeilengröße)"
 
-#: part/forms.py:57 stock/forms.py:250
+#: part/forms.py:57 stock/forms.py:254
 msgid "File Format"
 msgstr "Dateiformat"
 
-#: part/forms.py:57 stock/forms.py:250
+#: part/forms.py:57 stock/forms.py:254
 msgid "Select output file format"
 msgstr "Ausgabe-Dateiformat auswählen"
 
@@ -2068,11 +2079,11 @@ msgstr "Parameter"
 msgid "Confirm part creation"
 msgstr "Erstellen des Teils bestätigen"
 
-#: part/forms.py:247
+#: part/forms.py:248
 msgid "Input quantity for price calculation"
 msgstr "Eintragsmenge zur Preisberechnung"
 
-#: part/forms.py:250
+#: part/forms.py:251
 msgid "Select currency for price calculation"
 msgstr "Währung zur Preisberechnung wählen"
 
@@ -2088,131 +2099,131 @@ msgstr "Standard-Stichworte für Teile dieser Kategorie"
 msgid "Part Category"
 msgstr "Teilkategorie"
 
-#: part/models.py:76 part/templates/part/category.html:13
-#: part/templates/part/category.html:78 templates/stats.html:12
+#: part/models.py:76 part/templates/part/category.html:18
+#: part/templates/part/category.html:83 templates/stats.html:12
 msgid "Part Categories"
 msgstr "Teile-Kategorien"
 
-#: part/models.py:293 part/models.py:303
+#: part/models.py:345 part/models.py:355
 #, python-brace-format
 msgid "Part '{p1}' is  used in BOM for '{p2}' (recursive)"
 msgstr "Teil '{p1}' wird in Stückliste für Teil '{p2}' benutzt (rekursiv)"
 
-#: part/models.py:383
+#: part/models.py:435
 #, fuzzy
 #| msgid "No serial numbers found"
 msgid "Next available serial numbers are"
 msgstr "Keine Seriennummern gefunden"
 
-#: part/models.py:387
+#: part/models.py:439
 msgid "Next available serial number is"
 msgstr ""
 
-#: part/models.py:392
+#: part/models.py:444
 #, fuzzy
 #| msgid "Empty serial number string"
 msgid "Most recent serial number is"
 msgstr "Keine Seriennummer angegeben"
 
-#: part/models.py:470
+#: part/models.py:522
 msgid "Part must be unique for name, IPN and revision"
 msgstr "Namen, Teile- und Revisionsnummern müssen eindeutig sein"
 
-#: part/models.py:485 part/templates/part/detail.html:19
+#: part/models.py:537 part/templates/part/detail.html:19
 msgid "Part name"
 msgstr "Name des Teils"
 
-#: part/models.py:489
+#: part/models.py:541
 msgid "Is this part a template part?"
 msgstr "Ist dieses Teil eine Vorlage?"
 
-#: part/models.py:498
+#: part/models.py:550
 msgid "Is this part a variant of another part?"
 msgstr "Ist dieses Teil eine Variante eines anderen Teils?"
 
-#: part/models.py:500
+#: part/models.py:552
 msgid "Part description"
 msgstr "Beschreibung des Teils"
 
-#: part/models.py:502
+#: part/models.py:554
 msgid "Part keywords to improve visibility in search results"
 msgstr "Schlüsselworte um die Sichtbarkeit in Suchergebnissen zu verbessern"
 
-#: part/models.py:507
+#: part/models.py:559
 msgid "Part category"
 msgstr "Teile-Kategorie"
 
-#: part/models.py:509
+#: part/models.py:561
 msgid "Internal Part Number"
 msgstr "Interne Teilenummer"
 
-#: part/models.py:511
+#: part/models.py:563
 msgid "Part revision or version number"
 msgstr "Revisions- oder Versionsnummer"
 
-#: part/models.py:513
+#: part/models.py:565
 msgid "Link to extenal URL"
 msgstr "Link zu einer Externen URL"
 
-#: part/models.py:525
+#: part/models.py:577
 msgid "Where is this item normally stored?"
 msgstr "Wo wird dieses Teil normalerweise gelagert?"
 
-#: part/models.py:569
+#: part/models.py:621
 msgid "Default supplier part"
 msgstr "Standard-Zulieferer?"
 
-#: part/models.py:572
+#: part/models.py:624
 msgid "Minimum allowed stock level"
 msgstr "Minimal zulässiger Lagerbestand"
 
-#: part/models.py:574
+#: part/models.py:626
 msgid "Stock keeping units for this part"
 msgstr "Stock Keeping Units (SKU) für dieses Teil"
 
-#: part/models.py:576
+#: part/models.py:628
 msgid "Can this part be built from other parts?"
 msgstr "Kann dieses Teil aus anderen Teilen angefertigt werden?"
 
-#: part/models.py:578
+#: part/models.py:630
 msgid "Can this part be used to build other parts?"
 msgstr "Kann dieses Teil zum Bau von anderen genutzt werden?"
 
-#: part/models.py:580
+#: part/models.py:632
 msgid "Does this part have tracking for unique items?"
 msgstr "Hat dieses Teil Tracking für einzelne Objekte?"
 
-#: part/models.py:582
+#: part/models.py:634
 msgid "Can this part be purchased from external suppliers?"
 msgstr "Kann dieses Teil von externen Zulieferern gekauft werden?"
 
-#: part/models.py:584
+#: part/models.py:636
 msgid "Can this part be sold to customers?"
 msgstr "Kann dieses Teil an Kunden verkauft werden?"
 
-#: part/models.py:586
+#: part/models.py:638
 msgid "Is this part active?"
 msgstr "Ist dieses Teil aktiv?"
 
-#: part/models.py:588
+#: part/models.py:640
 msgid "Is this a virtual part, such as a software product or license?"
 msgstr "Ist dieses Teil virtuell, wie zum Beispiel eine Software oder Lizenz?"
 
-#: part/models.py:590
+#: part/models.py:642
 msgid "Part notes - supports Markdown formatting"
 msgstr "Bemerkungen - unterstüzt Markdown-Formatierung"
 
-#: part/models.py:592
+#: part/models.py:644
 msgid "Stored BOM checksum"
 msgstr "Prüfsumme der Stückliste gespeichert"
 
-#: part/models.py:1300
+#: part/models.py:1353
 #, fuzzy
 #| msgid "Stock item cannot be created for a template Part"
 msgid "Test templates can only be created for trackable parts"
 msgstr "Lagerobjekt kann nicht für Vorlagen-Teile angelegt werden"
 
-#: part/models.py:1317
+#: part/models.py:1370
 #, fuzzy
 #| msgid ""
 #| "A stock item with this serial number already exists for template part "
@@ -2222,114 +2233,120 @@ msgstr ""
 "Ein Teil mit dieser Seriennummer existiert bereits für die Teilevorlage "
 "{part}"
 
-#: part/models.py:1336 templates/js/part.html:455 templates/js/stock.html:92
+#: part/models.py:1389 templates/js/part.html:521 templates/js/stock.html:92
 #, fuzzy
 #| msgid "Instance Name"
 msgid "Test Name"
 msgstr "Instanzname"
 
-#: part/models.py:1337
+#: part/models.py:1390
 #, fuzzy
 #| msgid "Serial number for this item"
 msgid "Enter a name for the test"
 msgstr "Seriennummer für dieses Teil"
 
-#: part/models.py:1342
+#: part/models.py:1395
 #, fuzzy
 #| msgid "Description"
 msgid "Test Description"
 msgstr "Beschreibung"
 
-#: part/models.py:1343
+#: part/models.py:1396
 #, fuzzy
 #| msgid "Brief description of the build"
 msgid "Enter description for this test"
 msgstr "Kurze Beschreibung des Baus"
 
-#: part/models.py:1349
+#: part/models.py:1402
 msgid "Is this test required to pass?"
 msgstr ""
 
-#: part/models.py:1354 templates/js/part.html:472
+#: part/models.py:1407 templates/js/part.html:538
 #, fuzzy
 #| msgid "Required Parts"
 msgid "Requires Value"
 msgstr "benötigte Teile"
 
-#: part/models.py:1355
+#: part/models.py:1408
 msgid "Does this test require a value when adding a test result?"
 msgstr ""
 
-#: part/models.py:1360 templates/js/part.html:479
+#: part/models.py:1413 templates/js/part.html:545
 #, fuzzy
 #| msgid "Delete Attachment"
 msgid "Requires Attachment"
 msgstr "Anhang löschen"
 
-#: part/models.py:1361
+#: part/models.py:1414
 msgid "Does this test require a file attachment when adding a test result?"
 msgstr ""
 
-#: part/models.py:1394
+#: part/models.py:1447
 msgid "Parameter template name must be unique"
 msgstr "Vorlagen-Name des Parameters muss eindeutig sein"
 
-#: part/models.py:1399
+#: part/models.py:1452
 msgid "Parameter Name"
 msgstr "Name des Parameters"
 
-#: part/models.py:1401
+#: part/models.py:1454
 msgid "Parameter Units"
 msgstr "Parameter Einheit"
 
-#: part/models.py:1427
+#: part/models.py:1480
 msgid "Parent Part"
 msgstr "Ausgangsteil"
 
-#: part/models.py:1429
+#: part/models.py:1482
 msgid "Parameter Template"
 msgstr "Parameter Vorlage"
 
-#: part/models.py:1431
+#: part/models.py:1484
 msgid "Parameter Value"
 msgstr "Parameter Wert"
 
-#: part/models.py:1467
+#: part/models.py:1521
 msgid "Select parent part"
 msgstr "Ausgangsteil auswählen"
 
-#: part/models.py:1475
+#: part/models.py:1529
 msgid "Select part to be used in BOM"
 msgstr "Teil für die Nutzung in der Stückliste auswählen"
 
-#: part/models.py:1481
+#: part/models.py:1535
 msgid "BOM quantity for this BOM item"
 msgstr "Stücklisten-Anzahl für dieses Stücklisten-Teil"
 
-#: part/models.py:1484
+#: part/models.py:1537
+#, fuzzy
+#| msgid "Confim BOM item deletion"
+msgid "This BOM item is optional"
+msgstr "Löschung von BOM-Position bestätigen"
+
+#: part/models.py:1540
 msgid "Estimated build wastage quantity (absolute or percentage)"
 msgstr "Geschätzter Ausschuss (absolut oder prozentual)"
 
-#: part/models.py:1487
+#: part/models.py:1543
 msgid "BOM item reference"
 msgstr "Referenz des Objekts auf der Stückliste"
 
-#: part/models.py:1490
+#: part/models.py:1546
 msgid "BOM item notes"
 msgstr "Notizen zum Stücklisten-Objekt"
 
-#: part/models.py:1492
+#: part/models.py:1548
 msgid "BOM line checksum"
 msgstr "Prüfsumme der Stückliste"
 
-#: part/models.py:1556 part/views.py:1310 part/views.py:1362
-#: stock/models.py:229
+#: part/models.py:1612 part/views.py:1310 part/views.py:1362
+#: stock/models.py:231
 #, fuzzy
 #| msgid "Overage must be an integer value or a percentage"
 msgid "Quantity must be integer value for trackable parts"
 msgstr "Überschuss muss eine Ganzzahl oder ein Prozentwert sein"
 
-#: part/models.py:1565
+#: part/models.py:1621
 #, fuzzy
 #| msgid "New BOM Item"
 msgid "BOM Item"
@@ -2350,14 +2367,14 @@ msgstr "Bestellung"
 #: part/templates/part/allocation.html:45
 #: stock/templates/stock/item_base.html:8
 #: stock/templates/stock/item_base.html:58
-#: stock/templates/stock/item_base.html:226
+#: stock/templates/stock/item_base.html:238
 #: stock/templates/stock/stock_adjust.html:16 templates/js/build.html:112
-#: templates/js/stock.html:651
+#: templates/js/stock.html:660 templates/js/stock.html:896
 msgid "Stock Item"
 msgstr "Lagerobjekt"
 
 #: part/templates/part/allocation.html:20
-#: stock/templates/stock/item_base.html:180
+#: stock/templates/stock/item_base.html:192
 msgid "Build Order"
 msgstr "Bauauftrag"
 
@@ -2493,109 +2510,115 @@ msgstr "Neues Bild hochladen"
 msgid "Each part must already exist in the database"
 msgstr ""
 
-#: part/templates/part/category.html:14
+#: part/templates/part/category.html:19
 msgid "All parts"
 msgstr "Alle Teile"
 
-#: part/templates/part/category.html:18 part/views.py:1935
+#: part/templates/part/category.html:23 part/views.py:1976
 msgid "Create new part category"
 msgstr "Teilkategorie anlegen"
 
-#: part/templates/part/category.html:22
+#: part/templates/part/category.html:27
 #, fuzzy
 #| msgid "Edit Part Category"
 msgid "Edit part category"
 msgstr "Teilkategorie bearbeiten"
 
-#: part/templates/part/category.html:25
+#: part/templates/part/category.html:30
 #, fuzzy
 #| msgid "Select part category"
 msgid "Delete part category"
 msgstr "Teilekategorie wählen"
 
-#: part/templates/part/category.html:34 part/templates/part/category.html:73
+#: part/templates/part/category.html:39 part/templates/part/category.html:78
 msgid "Category Details"
 msgstr "Kategorie-Details"
 
-#: part/templates/part/category.html:39
+#: part/templates/part/category.html:44
 msgid "Category Path"
 msgstr "Pfad zur Kategorie"
 
-#: part/templates/part/category.html:44
+#: part/templates/part/category.html:49
 msgid "Category Description"
 msgstr "Kategorie-Beschreibung"
 
-#: part/templates/part/category.html:57 part/templates/part/detail.html:64
+#: part/templates/part/category.html:62 part/templates/part/detail.html:64
 msgid "Keywords"
 msgstr "Schlüsselwörter"
 
-#: part/templates/part/category.html:63
+#: part/templates/part/category.html:68
 msgid "Subcategories"
 msgstr "Unter-Kategorien"
 
-#: part/templates/part/category.html:68
+#: part/templates/part/category.html:73
 msgid "Parts (Including subcategories)"
 msgstr "Teile (inklusive Unter-Kategorien)"
 
-#: part/templates/part/category.html:101
+#: part/templates/part/category.html:106
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:102 part/views.py:491
+#: part/templates/part/category.html:107 part/views.py:491
 msgid "Create new part"
 msgstr "Neues Teil anlegen"
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:111
 #, fuzzy
 #| msgid "Part category"
 msgid "Set category"
 msgstr "Teile-Kategorie"
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:111
 #, fuzzy
 #| msgid "Set Part Category"
 msgid "Set Category"
 msgstr "Teilkategorie auswählen"
 
-#: part/templates/part/category.html:108
+#: part/templates/part/category.html:113
 #, fuzzy
 #| msgid "Export"
 msgid "Export Data"
 msgstr "Exportieren"
 
-#: part/templates/part/category.html:154
+#: part/templates/part/category.html:162
 #, fuzzy
 #| msgid "Create New Location"
 msgid "Create new location"
 msgstr "Neuen Standort anlegen"
 
-#: part/templates/part/category.html:159 part/templates/part/category.html:188
+#: part/templates/part/category.html:167 part/templates/part/category.html:196
 #, fuzzy
 #| msgid "Category"
 msgid "New Category"
 msgstr "Kategorie"
 
-#: part/templates/part/category.html:160
+#: part/templates/part/category.html:168
 #, fuzzy
 #| msgid "Create new part category"
 msgid "Create new category"
 msgstr "Teilkategorie anlegen"
 
-#: part/templates/part/category.html:189
+#: part/templates/part/category.html:197
 #, fuzzy
 #| msgid "Create new part category"
 msgid "Create new Part Category"
 msgstr "Teilkategorie anlegen"
 
-#: part/templates/part/category.html:195 stock/views.py:1213
+#: part/templates/part/category.html:203 stock/views.py:1314
 msgid "Create new Stock Location"
 msgstr "Neuen Lager-Standort erstellen"
 
+#: part/templates/part/category_tabs.html:9
+#, fuzzy
+#| msgid "Parameter Value"
+msgid "Parametric Table"
+msgstr "Parameter Wert"
+
 #: part/templates/part/detail.html:9
 msgid "Part Details"
 msgstr "Teile-Details"
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:82
+#: part/templates/part/detail.html:25 part/templates/part/part_base.html:85
 #: templates/js/part.html:112
 msgid "IPN"
 msgstr "IPN (Interne Produktnummer)"
@@ -2621,7 +2644,7 @@ msgid "Variant Of"
 msgstr "Variante von"
 
 #: part/templates/part/detail.html:70 part/templates/part/set_category.html:15
-#: templates/js/part.html:293
+#: templates/js/part.html:359
 msgid "Category"
 msgstr "Kategorie"
 
@@ -2661,8 +2684,8 @@ msgstr "Teil ist virtuell (kein physisches Teil)"
 msgid "Part is not a virtual part"
 msgstr "Teil ist nicht virtuell"
 
-#: part/templates/part/detail.html:145 stock/forms.py:244
-#: templates/js/table_filters.html:183
+#: part/templates/part/detail.html:145 stock/forms.py:248
+#: templates/js/table_filters.html:188
 msgid "Template"
 msgstr "Vorlage"
 
@@ -2678,7 +2701,7 @@ msgstr "Teil kann keine Vorlage sein wenn es Variante eines anderen Teils ist"
 msgid "Part is not a template part"
 msgstr "Teil ist nicht virtuell"
 
-#: part/templates/part/detail.html:154 templates/js/table_filters.html:195
+#: part/templates/part/detail.html:154 templates/js/table_filters.html:200
 msgid "Assembly"
 msgstr "Baugruppe"
 
@@ -2690,7 +2713,7 @@ msgstr "Teil kann aus anderen Teilen angefertigt werden"
 msgid "Part cannot be assembled from other parts"
 msgstr "Teil kann nicht aus anderen Teilen angefertigt werden"
 
-#: part/templates/part/detail.html:163 templates/js/table_filters.html:199
+#: part/templates/part/detail.html:163 templates/js/table_filters.html:204
 msgid "Component"
 msgstr "Komponente"
 
@@ -2702,7 +2725,7 @@ msgstr "Teil kann in Baugruppen benutzt werden"
 msgid "Part cannot be used in assemblies"
 msgstr "Teil kann nicht in Baugruppen benutzt werden"
 
-#: part/templates/part/detail.html:172 templates/js/table_filters.html:211
+#: part/templates/part/detail.html:172 templates/js/table_filters.html:216
 msgid "Trackable"
 msgstr "nachverfolgbar"
 
@@ -2722,7 +2745,7 @@ msgstr "Kaufbar"
 msgid "Part can be purchased from external suppliers"
 msgstr "Teil kann von externen Zulieferern gekauft werden"
 
-#: part/templates/part/detail.html:190 templates/js/table_filters.html:207
+#: part/templates/part/detail.html:190 templates/js/table_filters.html:212
 msgid "Salable"
 msgstr "Verkäuflich"
 
@@ -2734,7 +2757,7 @@ msgstr "Teil kann an Kunden verkauft werden"
 msgid "Part cannot be sold to customers"
 msgstr "Teil kann nicht an Kunden verkauft werden"
 
-#: part/templates/part/detail.html:199 templates/js/table_filters.html:178
+#: part/templates/part/detail.html:199 templates/js/table_filters.html:183
 msgid "Active"
 msgstr "Aktiv"
 
@@ -2770,7 +2793,7 @@ msgstr "Parameter hinzufügen"
 msgid "New Parameter"
 msgstr "Neuer Parameter"
 
-#: part/templates/part/params.html:21 stock/models.py:1340
+#: part/templates/part/params.html:21 stock/models.py:1391
 #: templates/js/stock.html:112
 msgid "Value"
 msgstr "Wert"
@@ -2801,82 +2824,82 @@ msgstr "Dieses Teil ist eine Vorlage."
 msgid "This part is a variant of"
 msgstr "Dieses Teil ist eine Variante von"
 
-#: part/templates/part/part_base.html:33 templates/js/company.html:153
-#: templates/js/part.html:270
+#: part/templates/part/part_base.html:36 templates/js/company.html:153
+#: templates/js/part.html:336
 msgid "Inactive"
 msgstr "Inaktiv"
 
-#: part/templates/part/part_base.html:40
+#: part/templates/part/part_base.html:43
 msgid "Star this part"
 msgstr "Teil favorisieren"
 
-#: part/templates/part/part_base.html:46
-#: stock/templates/stock/item_base.html:78
-#: stock/templates/stock/location.html:22
+#: part/templates/part/part_base.html:49
+#: stock/templates/stock/item_base.html:81
+#: stock/templates/stock/location.html:27
 #, fuzzy
 #| msgid "Source Location"
 msgid "Barcode actions"
 msgstr "Quell-Standort"
 
-#: part/templates/part/part_base.html:48
-#: stock/templates/stock/item_base.html:80
-#: stock/templates/stock/location.html:24
+#: part/templates/part/part_base.html:51
+#: stock/templates/stock/item_base.html:83
+#: stock/templates/stock/location.html:29
 #, fuzzy
 #| msgid "Part QR Code"
 msgid "Show QR Code"
 msgstr "Teil-QR-Code"
 
-#: part/templates/part/part_base.html:49
-#: stock/templates/stock/item_base.html:81
-#: stock/templates/stock/location.html:25
+#: part/templates/part/part_base.html:52
+#: stock/templates/stock/item_base.html:84
+#: stock/templates/stock/location.html:30
 msgid "Print Label"
 msgstr ""
 
-#: part/templates/part/part_base.html:53
+#: part/templates/part/part_base.html:56
 msgid "Show pricing information"
 msgstr "Kosteninformationen ansehen"
 
-#: part/templates/part/part_base.html:67
+#: part/templates/part/part_base.html:70
 #, fuzzy
 #| msgid "Source Location"
 msgid "Part actions"
 msgstr "Quell-Standort"
 
-#: part/templates/part/part_base.html:69
+#: part/templates/part/part_base.html:72
 #, fuzzy
 #| msgid "Duplicate Part"
 msgid "Duplicate part"
 msgstr "Teil duplizieren"
 
-#: part/templates/part/part_base.html:70
+#: part/templates/part/part_base.html:73
 #, fuzzy
 #| msgid "Edit Template"
 msgid "Edit part"
 msgstr "Vorlage bearbeiten"
 
-#: part/templates/part/part_base.html:72
+#: part/templates/part/part_base.html:75
 #, fuzzy
 #| msgid "Delete Parts"
 msgid "Delete part"
 msgstr "Teile löschen"
 
-#: part/templates/part/part_base.html:111 templates/js/table_filters.html:65
+#: part/templates/part/part_base.html:114 templates/js/table_filters.html:65
 msgid "In Stock"
 msgstr "Auf Lager"
 
-#: part/templates/part/part_base.html:118
+#: part/templates/part/part_base.html:121
 msgid "Allocated to Build Orders"
 msgstr "Zu Bauaufträgen zugeordnet"
 
-#: part/templates/part/part_base.html:125
+#: part/templates/part/part_base.html:128
 msgid "Allocated to Sales Orders"
 msgstr "Zu Aufträgen zugeordnet"
 
-#: part/templates/part/part_base.html:147
+#: part/templates/part/part_base.html:150
 msgid "Can Build"
 msgstr "Herstellbar?"
 
-#: part/templates/part/part_base.html:153
+#: part/templates/part/part_base.html:156
 msgid "Underway"
 msgstr "unterwegs"
 
@@ -2926,8 +2949,8 @@ msgstr "Teil entfernen"
 msgid "Part Stock"
 msgstr "Teilbestand"
 
-#: part/templates/part/stock_count.html:7 templates/js/bom.html:193
-#: templates/js/part.html:330
+#: part/templates/part/stock_count.html:7 templates/js/bom.html:197
+#: templates/js/part.html:396
 msgid "No Stock"
 msgstr "Kein Bestand"
 
@@ -2975,7 +2998,7 @@ msgstr "Stückliste"
 msgid "Used In"
 msgstr "Benutzt in"
 
-#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:270
+#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:282
 msgid "Tests"
 msgstr ""
 
@@ -3166,27 +3189,27 @@ msgstr "Teilparameter bearbeiten"
 msgid "Delete Part Parameter"
 msgstr "Teilparameter löschen"
 
-#: part/views.py:1886
+#: part/views.py:1927
 msgid "Edit Part Category"
 msgstr "Teilkategorie bearbeiten"
 
-#: part/views.py:1921
+#: part/views.py:1962
 msgid "Delete Part Category"
 msgstr "Teilkategorie löschen"
 
-#: part/views.py:1927
+#: part/views.py:1968
 msgid "Part category was deleted"
 msgstr "Teilekategorie wurde gelöscht"
 
-#: part/views.py:1986
+#: part/views.py:2027
 msgid "Create BOM item"
 msgstr "BOM-Position anlegen"
 
-#: part/views.py:2052
+#: part/views.py:2093
 msgid "Edit BOM item"
 msgstr "BOM-Position beaarbeiten"
 
-#: part/views.py:2100
+#: part/views.py:2141
 msgid "Confim BOM item deletion"
 msgstr "Löschung von BOM-Position bestätigen"
 
@@ -3226,308 +3249,346 @@ msgstr ""
 msgid "Asset file description"
 msgstr "Einstellungs-Beschreibung"
 
-#: stock/forms.py:187
+#: stock/forms.py:191
 msgid "Label"
 msgstr ""
 
-#: stock/forms.py:188 stock/forms.py:244
+#: stock/forms.py:192 stock/forms.py:248
 #, fuzzy
 #| msgid "Select stock item to allocate"
 msgid "Select test report template"
 msgstr "Lagerobjekt für Zuordnung auswählen"
 
-#: stock/forms.py:252
+#: stock/forms.py:256
 msgid "Include stock items in sub locations"
 msgstr "Lagerobjekte in untergeordneten Lagerorten einschließen"
 
-#: stock/forms.py:279
+#: stock/forms.py:291
+#, fuzzy
+#| msgid "No stock items matching query"
+msgid "Stock item to install"
+msgstr "Keine zur Anfrage passenden Lagerobjekte"
+
+#: stock/forms.py:298
+#, fuzzy
+#| msgid "Stock Quantity"
+msgid "Stock quantity to assign"
+msgstr "Bestand"
+
+#: stock/forms.py:326
+#, fuzzy
+#| msgid "Quantity must not exceed available stock quantity ({n})"
+msgid "Must not exceed available quantity"
+msgstr "Anzahl darf nicht die verfügbare Anzahl überschreiten ({n})"
+
+#: stock/forms.py:336
 #, fuzzy
 #| msgid "Does this part have tracking for unique items?"
 msgid "Destination location for uninstalled items"
 msgstr "Hat dieses Teil Tracking für einzelne Objekte?"
 
-#: stock/forms.py:281
+#: stock/forms.py:338
 #, fuzzy
 #| msgid "Description of the company"
 msgid "Add transaction note (optional)"
 msgstr "Firmenbeschreibung"
 
-#: stock/forms.py:283
+#: stock/forms.py:340
 #, fuzzy
 #| msgid "Confirm stock allocation"
 msgid "Confirm uninstall"
 msgstr "Lagerbestandszuordnung bestätigen"
 
-#: stock/forms.py:283
+#: stock/forms.py:340
 #, fuzzy
 #| msgid "Confirm movement of stock items"
 msgid "Confirm removal of installed stock items"
 msgstr "Bewegung der Lagerobjekte bestätigen"
 
-#: stock/forms.py:307
+#: stock/forms.py:364
 #, fuzzy
 #| msgid "Description"
 msgid "Destination"
 msgstr "Beschreibung"
 
-#: stock/forms.py:307
+#: stock/forms.py:364
 msgid "Destination stock location"
 msgstr "Ziel-Lagerbestand"
 
-#: stock/forms.py:309
+#: stock/forms.py:366
 msgid "Add note (required)"
 msgstr ""
 
-#: stock/forms.py:313 stock/views.py:795 stock/views.py:992
+#: stock/forms.py:370 stock/views.py:895 stock/views.py:1092
 msgid "Confirm stock adjustment"
 msgstr "Bestands-Anpassung bestätigen"
 
-#: stock/forms.py:313
+#: stock/forms.py:370
 msgid "Confirm movement of stock items"
 msgstr "Bewegung der Lagerobjekte bestätigen"
 
-#: stock/forms.py:315
+#: stock/forms.py:372
 #, fuzzy
 #| msgid "Default Location"
 msgid "Set Default Location"
 msgstr "Standard-Lagerort"
 
-#: stock/forms.py:315
+#: stock/forms.py:372
 msgid "Set the destination as the default location for selected parts"
 msgstr "Setze das Ziel als Standard-Ziel für ausgewählte Teile"
 
-#: stock/models.py:210
+#: stock/models.py:212
 #, fuzzy
 #| msgid "A stock item with this serial number already exists"
 msgid "StockItem with this serial number already exists"
 msgstr "Ein Teil mit dieser Seriennummer existiert bereits"
 
-#: stock/models.py:246
+#: stock/models.py:248
 #, python-brace-format
 msgid "Part type ('{pf}') must be {pe}"
 msgstr "Teile-Typ ('{pf}') muss {pe} sein"
 
-#: stock/models.py:256 stock/models.py:265
+#: stock/models.py:258 stock/models.py:267
 msgid "Quantity must be 1 for item with a serial number"
 msgstr "Anzahl muss für Objekte mit Seriennummer \"1\" sein"
 
-#: stock/models.py:257
+#: stock/models.py:259
 msgid "Serial number cannot be set if quantity greater than 1"
 msgstr ""
 "Seriennummer kann nicht gesetzt werden wenn die Anzahl größer als \"1\" ist"
 
-#: stock/models.py:278
+#: stock/models.py:281
 msgid "Item cannot belong to itself"
 msgstr "Teil kann nicht zu sich selbst gehören"
 
-#: stock/models.py:311
+#: stock/models.py:287
+msgid "Item must have a build reference if is_building=True"
+msgstr ""
+
+#: stock/models.py:294
+msgid "Build reference does not point to the same part object"
+msgstr ""
+
+#: stock/models.py:327
 msgid "Parent Stock Item"
 msgstr "Eltern-Lagerobjekt"
 
-#: stock/models.py:320
+#: stock/models.py:336
 msgid "Base part"
 msgstr "Basis-Teil"
 
-#: stock/models.py:329
+#: stock/models.py:345
 msgid "Select a matching supplier part for this stock item"
 msgstr "Passenden Zulieferer für dieses Lagerobjekt auswählen"
 
-#: stock/models.py:334 stock/templates/stock/stock_app_base.html:7
+#: stock/models.py:350 stock/templates/stock/stock_app_base.html:7
 msgid "Stock Location"
 msgstr "Lagerort"
 
-#: stock/models.py:337
+#: stock/models.py:353
 msgid "Where is this stock item located?"
 msgstr "Wo wird dieses Teil normalerweise gelagert?"
 
-#: stock/models.py:342
+#: stock/models.py:358 stock/templates/stock/item_base.html:177
 msgid "Installed In"
 msgstr "Installiert in"
 
-#: stock/models.py:345
+#: stock/models.py:361
 msgid "Is this item installed in another item?"
 msgstr "Ist dieses Teil in einem anderen verbaut?"
 
-#: stock/models.py:361
+#: stock/models.py:377
 msgid "Serial number for this item"
 msgstr "Seriennummer für dieses Teil"
 
-#: stock/models.py:373
+#: stock/models.py:389
 msgid "Batch code for this stock item"
 msgstr "Losnummer für dieses Lagerobjekt"
 
-#: stock/models.py:377
+#: stock/models.py:393
 msgid "Stock Quantity"
 msgstr "Bestand"
 
-#: stock/models.py:386
+#: stock/models.py:402
 msgid "Source Build"
 msgstr "Quellbau"
 
-#: stock/models.py:388
+#: stock/models.py:404
 msgid "Build for this stock item"
 msgstr "Bau für dieses Lagerobjekt"
 
-#: stock/models.py:395
+#: stock/models.py:415
 msgid "Source Purchase Order"
 msgstr "Quellbestellung"
 
-#: stock/models.py:398
+#: stock/models.py:418
 msgid "Purchase order for this stock item"
 msgstr "Bestellung für dieses Teil"
 
-#: stock/models.py:404
+#: stock/models.py:424
 msgid "Destination Sales Order"
 msgstr "Zielauftrag"
 
-#: stock/models.py:411
+#: stock/models.py:431
 msgid "Destination Build Order"
 msgstr "Zielbauauftrag"
 
-#: stock/models.py:424
+#: stock/models.py:444
 msgid "Delete this Stock Item when stock is depleted"
 msgstr "Objekt löschen wenn Lagerbestand aufgebraucht"
 
-#: stock/models.py:434 stock/templates/stock/item_notes.html:14
+#: stock/models.py:454 stock/templates/stock/item_notes.html:14
 #: stock/templates/stock/item_notes.html:30
 msgid "Stock Item Notes"
 msgstr "Lagerobjekt-Notizen"
 
-#: stock/models.py:485
+#: stock/models.py:505
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Assigned to Customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: stock/models.py:487
+#: stock/models.py:507
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Manually assigned to customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: stock/models.py:500
+#: stock/models.py:520
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Returned from customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: stock/models.py:502
+#: stock/models.py:522
 #, fuzzy
 #| msgid "Create new stock location"
 msgid "Returned to location"
 msgstr "Neuen Lagerort anlegen"
 
-#: stock/models.py:626
+#: stock/models.py:650
 #, fuzzy
 #| msgid "Installed in Stock Item"
-msgid "Installed in stock item"
+msgid "Installed into stock item"
 msgstr "In Lagerobjekt installiert"
 
-#: stock/models.py:655
+#: stock/models.py:658
+#, fuzzy
+#| msgid "Installed in Stock Item"
+msgid "Installed stock item"
+msgstr "In Lagerobjekt installiert"
+
+#: stock/models.py:682
+#, fuzzy
+#| msgid "Installed in Stock Item"
+msgid "Uninstalled stock item"
+msgstr "In Lagerobjekt installiert"
+
+#: stock/models.py:701
 #, fuzzy
 #| msgid "Include sublocations"
 msgid "Uninstalled into location"
 msgstr "Unterlagerorte einschließen"
 
-#: stock/models.py:745
+#: stock/models.py:796
 #, fuzzy
 #| msgid "Part is not a virtual part"
 msgid "Part is not set as trackable"
 msgstr "Teil ist nicht virtuell"
 
-#: stock/models.py:751
+#: stock/models.py:802
 msgid "Quantity must be integer"
 msgstr "Anzahl muss eine Ganzzahl sein"
 
-#: stock/models.py:757
+#: stock/models.py:808
 #, python-brace-format
 msgid "Quantity must not exceed available stock quantity ({n})"
 msgstr "Anzahl darf nicht die verfügbare Anzahl überschreiten ({n})"
 
-#: stock/models.py:760
+#: stock/models.py:811
 msgid "Serial numbers must be a list of integers"
 msgstr "Seriennummern muss eine Liste von Ganzzahlen sein"
 
-#: stock/models.py:763
+#: stock/models.py:814
 msgid "Quantity does not match serial numbers"
 msgstr "Anzahl stimmt nicht mit den Seriennummern überein"
 
-#: stock/models.py:773
+#: stock/models.py:824
 msgid "Serial numbers already exist: "
 msgstr "Seriennummern existieren bereits:"
 
-#: stock/models.py:798
+#: stock/models.py:849
 msgid "Add serial number"
 msgstr "Seriennummer hinzufügen"
 
-#: stock/models.py:801
+#: stock/models.py:852
 #, python-brace-format
 msgid "Serialized {n} items"
 msgstr "{n} Teile serialisiert"
 
-#: stock/models.py:912
+#: stock/models.py:963
 msgid "StockItem cannot be moved as it is not in stock"
 msgstr "Lagerobjekt kann nicht bewegt werden, da kein Bestand vorhanden ist"
 
-#: stock/models.py:1241
+#: stock/models.py:1292
 msgid "Tracking entry title"
 msgstr "Name des Eintrags-Trackings"
 
-#: stock/models.py:1243
+#: stock/models.py:1294
 msgid "Entry notes"
 msgstr "Eintrags-Notizen"
 
-#: stock/models.py:1245
+#: stock/models.py:1296
 msgid "Link to external page for further information"
 msgstr "Link auf externe Seite für weitere Informationen"
 
-#: stock/models.py:1305
+#: stock/models.py:1356
 #, fuzzy
 #| msgid "Serial number for this item"
 msgid "Value must be provided for this test"
 msgstr "Seriennummer für dieses Teil"
 
-#: stock/models.py:1311
+#: stock/models.py:1362
 msgid "Attachment must be uploaded for this test"
 msgstr ""
 
-#: stock/models.py:1328
+#: stock/models.py:1379
 msgid "Test"
 msgstr ""
 
-#: stock/models.py:1329
+#: stock/models.py:1380
 #, fuzzy
 #| msgid "Part name"
 msgid "Test name"
 msgstr "Name des Teils"
 
-#: stock/models.py:1334
+#: stock/models.py:1385
 #, fuzzy
 #| msgid "Search Results"
 msgid "Result"
 msgstr "Suchergebnisse"
 
-#: stock/models.py:1335 templates/js/table_filters.html:111
+#: stock/models.py:1386 templates/js/table_filters.html:111
 msgid "Test result"
 msgstr ""
 
-#: stock/models.py:1341
+#: stock/models.py:1392
 msgid "Test output value"
 msgstr ""
 
-#: stock/models.py:1347
+#: stock/models.py:1398
 #, fuzzy
 #| msgid "Attachments"
 msgid "Attachment"
 msgstr "Anhänge"
 
-#: stock/models.py:1348
+#: stock/models.py:1399
 #, fuzzy
 #| msgid "Delete attachment"
 msgid "Test result attachment"
 msgstr "Anhang löschen"
 
-#: stock/models.py:1354
+#: stock/models.py:1405
 #, fuzzy
 #| msgid "Edit notes"
 msgid "Test notes"
@@ -3576,124 +3637,130 @@ msgstr ""
 "Dieses Lagerobjekt wird automatisch gelöscht wenn der Lagerbestand "
 "aufgebraucht ist."
 
-#: stock/templates/stock/item_base.html:83 templates/js/barcode.html:283
+#: stock/templates/stock/item_base.html:86 templates/js/barcode.html:283
 #: templates/js/barcode.html:288
 msgid "Unlink Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:85
+#: stock/templates/stock/item_base.html:88
 msgid "Link Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:91
+#: stock/templates/stock/item_base.html:94
 #, fuzzy
 #| msgid "Confirm stock adjustment"
 msgid "Stock adjustment actions"
 msgstr "Bestands-Anpassung bestätigen"
 
-#: stock/templates/stock/item_base.html:95
-#: stock/templates/stock/location.html:33 templates/stock_table.html:14
+#: stock/templates/stock/item_base.html:98
+#: stock/templates/stock/location.html:38 templates/stock_table.html:15
 msgid "Count stock"
 msgstr "Bestand zählen"
 
-#: stock/templates/stock/item_base.html:96 templates/stock_table.html:12
+#: stock/templates/stock/item_base.html:99 templates/stock_table.html:13
 msgid "Add stock"
 msgstr "Bestand hinzufügen"
 
-#: stock/templates/stock/item_base.html:97 templates/stock_table.html:13
+#: stock/templates/stock/item_base.html:100 templates/stock_table.html:14
 msgid "Remove stock"
 msgstr "Bestand entfernen"
 
-#: stock/templates/stock/item_base.html:99
+#: stock/templates/stock/item_base.html:102
 #, fuzzy
 #| msgid "Order stock"
 msgid "Transfer stock"
 msgstr "Bestand bestellen"
 
-#: stock/templates/stock/item_base.html:101
+#: stock/templates/stock/item_base.html:104
 #, fuzzy
 #| msgid "Serialize Stock"
 msgid "Serialize stock"
 msgstr "Lagerbestand erfassen"
 
-#: stock/templates/stock/item_base.html:105
+#: stock/templates/stock/item_base.html:108
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Assign to customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: stock/templates/stock/item_base.html:108
+#: stock/templates/stock/item_base.html:111
 #, fuzzy
 #| msgid "Count stock"
 msgid "Return to stock"
 msgstr "Bestand zählen"
 
-#: stock/templates/stock/item_base.html:114
-#: stock/templates/stock/location.html:30
+#: stock/templates/stock/item_base.html:115 templates/js/stock.html:933
+#, fuzzy
+#| msgid "Installed in Stock Item"
+msgid "Uninstall stock item"
+msgstr "In Lagerobjekt installiert"
+
+#: stock/templates/stock/item_base.html:115
+msgid "Uninstall"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:122
+#: stock/templates/stock/location.html:35
 #, fuzzy
 #| msgid "Stock Locations"
 msgid "Stock actions"
 msgstr "Lagerobjekt-Standorte"
 
-#: stock/templates/stock/item_base.html:118
+#: stock/templates/stock/item_base.html:126
 #, fuzzy
 #| msgid "Count stock items"
 msgid "Convert to variant"
 msgstr "Lagerobjekte zählen"
 
-#: stock/templates/stock/item_base.html:120
+#: stock/templates/stock/item_base.html:128
 #, fuzzy
 #| msgid "Count stock items"
 msgid "Duplicate stock item"
 msgstr "Lagerobjekte zählen"
 
-#: stock/templates/stock/item_base.html:121
+#: stock/templates/stock/item_base.html:129
 #, fuzzy
 #| msgid "Edit Stock Item"
 msgid "Edit stock item"
 msgstr "Lagerobjekt bearbeiten"
 
-#: stock/templates/stock/item_base.html:123
+#: stock/templates/stock/item_base.html:131
 #, fuzzy
 #| msgid "Delete Stock Item"
 msgid "Delete stock item"
 msgstr "Lagerobjekt löschen"
 
-#: stock/templates/stock/item_base.html:127
+#: stock/templates/stock/item_base.html:135
 msgid "Generate test report"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:135
+#: stock/templates/stock/item_base.html:143
 msgid "Stock Item Details"
 msgstr "Lagerbestands-Details"
 
-#: stock/templates/stock/item_base.html:168
-msgid "Belongs To"
-msgstr "Gehört zu"
-
-#: stock/templates/stock/item_base.html:190
+#: stock/templates/stock/item_base.html:202
 #, fuzzy
 #| msgid "No stock location set"
 msgid "No location set"
 msgstr "Kein Lagerort gesetzt"
 
-#: stock/templates/stock/item_base.html:197
+#: stock/templates/stock/item_base.html:209
 msgid "Unique Identifier"
 msgstr "Eindeutiger Bezeichner"
 
-#: stock/templates/stock/item_base.html:225
+#: stock/templates/stock/item_base.html:237
 msgid "Parent Item"
 msgstr "Elternposition"
 
-#: stock/templates/stock/item_base.html:250
+#: stock/templates/stock/item_base.html:262
 msgid "Last Updated"
 msgstr "Zuletzt aktualisiert"
 
-#: stock/templates/stock/item_base.html:255
+#: stock/templates/stock/item_base.html:267
 msgid "Last Stocktake"
 msgstr "Letzte Inventur"
 
-#: stock/templates/stock/item_base.html:259
+#: stock/templates/stock/item_base.html:271
 msgid "No stocktake performed"
 msgstr "Keine Inventur ausgeführt"
 
@@ -3711,35 +3778,40 @@ msgstr "Dieses Lagerobjekt hat keine Kinder"
 msgid "Are you sure you want to delete this stock item?"
 msgstr "Sind Sie sicher, dass Sie diesen Anhang löschen wollen?"
 
+#: stock/templates/stock/item_install.html:7
+#, fuzzy
+#| msgid "Installed in Stock Item"
+msgid "Install another StockItem into this item."
+msgstr "In Lagerobjekt installiert"
+
+#: stock/templates/stock/item_install.html:10
+msgid "Stock items can only be installed if they meet the following criteria"
+msgstr ""
+
+#: stock/templates/stock/item_install.html:13
+msgid "The StockItem links to a Part which is in the BOM for this StockItem"
+msgstr ""
+
+#: stock/templates/stock/item_install.html:14
+#, fuzzy
+#| msgid "This stock item is allocated to Build"
+msgid "The StockItem is currently in stock"
+msgstr "Dieses Lagerobjekt ist dem Bau zugewiesen"
+
 #: stock/templates/stock/item_installed.html:10
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Installed Stock Items"
 msgstr "In Lagerobjekt installiert"
 
-#: stock/templates/stock/item_installed.html:18
+#: stock/templates/stock/item_serialize.html:5
 #, fuzzy
-#| msgid "Added stock to {n} items"
-msgid "Uninstall selected stock items"
-msgstr "Vorrat zu {n} Lagerobjekten hinzugefügt"
+#| msgid "Purchase order for this stock item"
+msgid "Create serialized items from this stock item."
+msgstr "Bestellung für dieses Teil"
 
-#: stock/templates/stock/item_installed.html:18
-msgid "Uninstall"
-msgstr ""
-
-#: stock/templates/stock/item_installed.html:35
-#, fuzzy
-#| msgid "No stock items matching query"
-msgid "No stock items installed"
-msgstr "Keine zur Anfrage passenden Lagerobjekte"
-
-#: stock/templates/stock/item_installed.html:48 templates/js/part.html:209
-#: templates/js/stock.html:409
-msgid "Select"
-msgstr "Auswählen"
-
-#: stock/templates/stock/item_installed.html:131
-msgid "Uninstall item"
+#: stock/templates/stock/item_serialize.html:7
+msgid "Select quantity to serialize, and unique serial numbers."
 msgstr ""
 
 #: stock/templates/stock/item_tests.html:10 stock/templates/stock/tabs.html:13
@@ -3760,62 +3832,62 @@ msgstr ""
 msgid "Test Report"
 msgstr ""
 
-#: stock/templates/stock/location.html:13
+#: stock/templates/stock/location.html:18
 msgid "All stock items"
 msgstr "Alle Lagerobjekte"
 
-#: stock/templates/stock/location.html:26
+#: stock/templates/stock/location.html:31
 #, fuzzy
 #| msgid "Child Stock Items"
 msgid "Check-in Items"
 msgstr "Kind-Lagerobjekte"
 
-#: stock/templates/stock/location.html:37
+#: stock/templates/stock/location.html:42
 #, fuzzy
 #| msgid "Location Description"
 msgid "Location actions"
 msgstr "Standort-Beschreibung"
 
-#: stock/templates/stock/location.html:39
+#: stock/templates/stock/location.html:44
 #, fuzzy
 #| msgid "Edit stock location"
 msgid "Edit location"
 msgstr "Lagerort bearbeiten"
 
-#: stock/templates/stock/location.html:40
+#: stock/templates/stock/location.html:45
 #, fuzzy
 #| msgid "Delete stock location"
 msgid "Delete location"
 msgstr "Lagerort löschen"
 
-#: stock/templates/stock/location.html:48
+#: stock/templates/stock/location.html:53
 msgid "Location Details"
 msgstr "Standort-Details"
 
-#: stock/templates/stock/location.html:53
+#: stock/templates/stock/location.html:58
 msgid "Location Path"
 msgstr "Standord-Pfad"
 
-#: stock/templates/stock/location.html:58
+#: stock/templates/stock/location.html:63
 msgid "Location Description"
 msgstr "Standort-Beschreibung"
 
-#: stock/templates/stock/location.html:63
+#: stock/templates/stock/location.html:68
 msgid "Sublocations"
 msgstr "Sub-Standorte"
 
-#: stock/templates/stock/location.html:68
-#: stock/templates/stock/location.html:83
+#: stock/templates/stock/location.html:73
+#: stock/templates/stock/location.html:88
 #: templates/InvenTree/search_stock_items.html:6 templates/stats.html:21
 #: templates/stats.html:30
 msgid "Stock Items"
 msgstr "Lagerobjekte"
 
-#: stock/templates/stock/location.html:73
+#: stock/templates/stock/location.html:78
 msgid "Stock Details"
 msgstr "Objekt-Details"
 
-#: stock/templates/stock/location.html:78
+#: stock/templates/stock/location.html:83
 #: templates/InvenTree/search_stock_location.html:6 templates/stats.html:25
 msgid "Stock Locations"
 msgstr "Lagerobjekt-Standorte"
@@ -3832,7 +3904,7 @@ msgstr "Sind Sie sicher, dass Sie diesen Anhang löschen wollen?"
 msgid "The following stock items will be uninstalled"
 msgstr "Die folgenden Objekte werden erstellt"
 
-#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1186
+#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1287
 #, fuzzy
 #| msgid "Count Stock Items"
 msgid "Convert Stock Item"
@@ -3978,138 +4050,144 @@ msgstr "Lagerbestandsexportoptionen"
 msgid "Stock Item QR Code"
 msgstr "Lagerobjekt-QR-Code"
 
-#: stock/views.py:699
+#: stock/views.py:700
+#, fuzzy
+#| msgid "Installed in Stock Item"
+msgid "Install Stock Item"
+msgstr "In Lagerobjekt installiert"
+
+#: stock/views.py:799
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Uninstall Stock Items"
 msgstr "In Lagerobjekt installiert"
 
-#: stock/views.py:806
+#: stock/views.py:906
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Uninstalled stock items"
 msgstr "In Lagerobjekt installiert"
 
-#: stock/views.py:831
+#: stock/views.py:931
 msgid "Adjust Stock"
 msgstr "Lagerbestand anpassen"
 
-#: stock/views.py:940
+#: stock/views.py:1040
 msgid "Move Stock Items"
 msgstr "Lagerobjekte bewegen"
 
-#: stock/views.py:941
+#: stock/views.py:1041
 msgid "Count Stock Items"
 msgstr "Lagerobjekte zählen"
 
-#: stock/views.py:942
+#: stock/views.py:1042
 msgid "Remove From Stock"
 msgstr "Aus Lagerbestand entfernen"
 
-#: stock/views.py:943
+#: stock/views.py:1043
 msgid "Add Stock Items"
 msgstr "Lagerobjekte hinzufügen"
 
-#: stock/views.py:944
+#: stock/views.py:1044
 msgid "Delete Stock Items"
 msgstr "Lagerobjekte löschen"
 
-#: stock/views.py:972
+#: stock/views.py:1072
 msgid "Must enter integer value"
 msgstr "Nur Ganzzahl eingeben"
 
-#: stock/views.py:977
+#: stock/views.py:1077
 msgid "Quantity must be positive"
 msgstr "Anzahl muss positiv sein"
 
-#: stock/views.py:984
+#: stock/views.py:1084
 #, python-brace-format
 msgid "Quantity must not exceed {x}"
 msgstr "Anzahl darf {x} nicht überschreiten"
 
-#: stock/views.py:1063
+#: stock/views.py:1163
 #, python-brace-format
 msgid "Added stock to {n} items"
 msgstr "Vorrat zu {n} Lagerobjekten hinzugefügt"
 
-#: stock/views.py:1078
+#: stock/views.py:1178
 #, python-brace-format
 msgid "Removed stock from {n} items"
 msgstr "Vorrat von {n} Lagerobjekten entfernt"
 
-#: stock/views.py:1091
+#: stock/views.py:1191
 #, python-brace-format
 msgid "Counted stock for {n} items"
 msgstr "Bestand für {n} Objekte erfasst"
 
-#: stock/views.py:1119
+#: stock/views.py:1219
 msgid "No items were moved"
 msgstr "Keine Lagerobjekte wurden bewegt"
 
-#: stock/views.py:1122
+#: stock/views.py:1222
 #, python-brace-format
 msgid "Moved {n} items to {dest}"
 msgstr "{n} Teile nach {dest} bewegt"
 
-#: stock/views.py:1141
+#: stock/views.py:1241
 #, python-brace-format
 msgid "Deleted {n} stock items"
 msgstr "{n} Teile im Lager gelöscht"
 
-#: stock/views.py:1153
+#: stock/views.py:1253
 msgid "Edit Stock Item"
 msgstr "Lagerobjekt bearbeiten"
 
-#: stock/views.py:1234
+#: stock/views.py:1335
 msgid "Serialize Stock"
 msgstr "Lagerbestand erfassen"
 
-#: stock/views.py:1426
+#: stock/views.py:1527
 #, fuzzy
 #| msgid "Count stock items"
 msgid "Duplicate Stock Item"
 msgstr "Lagerobjekte zählen"
 
-#: stock/views.py:1492
+#: stock/views.py:1593
 msgid "Invalid quantity"
 msgstr "Ungültige Menge"
 
-#: stock/views.py:1495
+#: stock/views.py:1596
 #, fuzzy
 #| msgid "Quantity must be greater than zero"
 msgid "Quantity cannot be less than zero"
 msgstr "Anzahl muss größer Null sein"
 
-#: stock/views.py:1499
+#: stock/views.py:1600
 msgid "Invalid part selection"
 msgstr "Ungültige Teileauswahl"
 
-#: stock/views.py:1548
+#: stock/views.py:1649
 #, python-brace-format
 msgid "Created {n} new stock items"
 msgstr "{n} neue Lagerobjekte erstellt"
 
-#: stock/views.py:1567 stock/views.py:1583
+#: stock/views.py:1668 stock/views.py:1684
 msgid "Created new stock item"
 msgstr "Neues Lagerobjekt erstellt"
 
-#: stock/views.py:1602
+#: stock/views.py:1703
 msgid "Delete Stock Location"
 msgstr "Standort löschen"
 
-#: stock/views.py:1615
+#: stock/views.py:1716
 msgid "Delete Stock Item"
 msgstr "Lagerobjekt löschen"
 
-#: stock/views.py:1626
+#: stock/views.py:1727
 msgid "Delete Stock Tracking Entry"
 msgstr "Lagerbestands-Tracking-Eintrag löschen"
 
-#: stock/views.py:1643
+#: stock/views.py:1744
 msgid "Edit Stock Tracking Entry"
 msgstr "Lagerbestands-Tracking-Eintrag bearbeiten"
 
-#: stock/views.py:1652
+#: stock/views.py:1753
 msgid "Add Stock Tracking Entry"
 msgstr "Lagerbestands-Tracking-Eintrag hinzufügen"
 
@@ -4145,17 +4223,25 @@ msgstr "Bau fertigstellen"
 msgid "Search Results"
 msgstr "Suchergebnisse"
 
-#: templates/InvenTree/search.html:22
-msgid "No results found"
+#: templates/InvenTree/search.html:24
+#, fuzzy
+#| msgid "No results found"
+msgid "No results found for "
 msgstr "Keine Ergebnisse gefunden"
 
-#: templates/InvenTree/search.html:181 templates/js/stock.html:521
+#: templates/InvenTree/search.html:42
+#, fuzzy
+#| msgid "Cancel sales order"
+msgid "Enter a search query"
+msgstr "Auftrag stornieren"
+
+#: templates/InvenTree/search.html:191 templates/js/stock.html:527
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Shipped to customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: templates/InvenTree/search.html:184 templates/js/stock.html:528
+#: templates/InvenTree/search.html:194 templates/js/stock.html:537
 msgid "No stock location set"
 msgstr "Kein Lagerort gesetzt"
 
@@ -4366,33 +4452,39 @@ msgstr "Neues Lagerobjekt hinzufügen"
 msgid "Open subassembly"
 msgstr "Unterbaugruppe öffnen"
 
-#: templates/js/bom.html:184 templates/js/build.html:119
+#: templates/js/bom.html:173
+#, fuzzy
+#| msgid "Options"
+msgid "Optional"
+msgstr "Optionen"
+
+#: templates/js/bom.html:188 templates/js/build.html:119
 msgid "Available"
 msgstr "verfügbar"
 
-#: templates/js/bom.html:209
+#: templates/js/bom.html:213
 msgid "No pricing available"
 msgstr "Keine Preisinformation verfügbar"
 
-#: templates/js/bom.html:228
+#: templates/js/bom.html:232
 #, fuzzy
 #| msgid "Options"
 msgid "Actions"
 msgstr "Optionen"
 
-#: templates/js/bom.html:236
+#: templates/js/bom.html:240
 msgid "Validate BOM Item"
 msgstr "BOM-Position validieren"
 
-#: templates/js/bom.html:238
+#: templates/js/bom.html:242
 msgid "This line has been validated"
 msgstr "Diese Position wurde validiert"
 
-#: templates/js/bom.html:240
+#: templates/js/bom.html:244
 msgid "Edit BOM Item"
 msgstr "BOM-Position bearbeiten"
 
-#: templates/js/bom.html:242
+#: templates/js/bom.html:246
 msgid "Delete BOM Item"
 msgstr "BOM-Position löschen"
 
@@ -4424,11 +4516,11 @@ msgstr "Keine Firmeninformation gefunden"
 msgid "No supplier parts found"
 msgstr "Keine Zuliefererteile gefunden"
 
-#: templates/js/company.html:145 templates/js/part.html:248
+#: templates/js/company.html:145 templates/js/part.html:314
 msgid "Template part"
 msgstr "Vorlagenteil"
 
-#: templates/js/company.html:149 templates/js/part.html:252
+#: templates/js/company.html:149 templates/js/part.html:318
 msgid "Assembled part"
 msgstr "Baugruppe"
 
@@ -4440,7 +4532,7 @@ msgstr "Link"
 msgid "No purchase orders found"
 msgstr "Keine Bestellungen gefunden"
 
-#: templates/js/order.html:172 templates/js/stock.html:633
+#: templates/js/order.html:172 templates/js/stock.html:642
 msgid "Date"
 msgstr "Datum"
 
@@ -4458,57 +4550,62 @@ msgstr "Versanddatum"
 msgid "No variants found"
 msgstr "Keine Teile gefunden"
 
-#: templates/js/part.html:256
-msgid "Starred part"
-msgstr "Favoritenteil"
-
-#: templates/js/part.html:260
-msgid "Salable part"
-msgstr "Verkäufliches Teil"
-
-#: templates/js/part.html:299
-msgid "No category"
-msgstr "Keine Kategorie"
-
-#: templates/js/part.html:317 templates/js/table_filters.html:191
-msgid "Low stock"
-msgstr "Bestand niedrig"
-
-#: templates/js/part.html:326
-msgid "Building"
-msgstr "Im Bau"
-
-#: templates/js/part.html:345
+#: templates/js/part.html:223 templates/js/part.html:411
 msgid "No parts found"
 msgstr "Keine Teile gefunden"
 
-#: templates/js/part.html:405
+#: templates/js/part.html:275 templates/js/stock.html:409
+#: templates/js/stock.html:965
+msgid "Select"
+msgstr "Auswählen"
+
+#: templates/js/part.html:322
+msgid "Starred part"
+msgstr "Favoritenteil"
+
+#: templates/js/part.html:326
+msgid "Salable part"
+msgstr "Verkäufliches Teil"
+
+#: templates/js/part.html:365
+msgid "No category"
+msgstr "Keine Kategorie"
+
+#: templates/js/part.html:383 templates/js/table_filters.html:196
+msgid "Low stock"
+msgstr "Bestand niedrig"
+
+#: templates/js/part.html:392
+msgid "Building"
+msgstr "Im Bau"
+
+#: templates/js/part.html:471
 msgid "YES"
 msgstr ""
 
-#: templates/js/part.html:407
+#: templates/js/part.html:473
 msgid "NO"
 msgstr ""
 
-#: templates/js/part.html:441
+#: templates/js/part.html:507
 #, fuzzy
 #| msgid "No stock items matching query"
 msgid "No test templates matching query"
 msgstr "Keine zur Anfrage passenden Lagerobjekte"
 
-#: templates/js/part.html:492 templates/js/stock.html:63
+#: templates/js/part.html:558 templates/js/stock.html:63
 #, fuzzy
 #| msgid "Edit Sales Order"
 msgid "Edit test result"
 msgstr "Auftrag bearbeiten"
 
-#: templates/js/part.html:493 templates/js/stock.html:64
+#: templates/js/part.html:559 templates/js/stock.html:64
 #, fuzzy
 #| msgid "Delete attachment"
 msgid "Delete test result"
 msgstr "Anhang löschen"
 
-#: templates/js/part.html:499
+#: templates/js/part.html:565
 msgid "This test is defined for a parent part"
 msgstr ""
 
@@ -4564,54 +4661,84 @@ msgstr "Lagerobjekt wurde zugewiesen"
 msgid "Stock item has been assigned to customer"
 msgstr "Lagerobjekt wurde zugewiesen"
 
-#: templates/js/stock.html:474
+#: templates/js/stock.html:475
 #, fuzzy
 #| msgid "This stock item is allocated to Sales Order"
 msgid "Stock item was assigned to a build order"
 msgstr "Dieses Lagerobjekt ist dem Auftrag zugewiesen"
 
-#: templates/js/stock.html:476
+#: templates/js/stock.html:477
 #, fuzzy
 #| msgid "This stock item is allocated to Sales Order"
 msgid "Stock item was assigned to a sales order"
 msgstr "Dieses Lagerobjekt ist dem Auftrag zugewiesen"
 
-#: templates/js/stock.html:483
+#: templates/js/stock.html:482
+#, fuzzy
+#| msgid "Is this item installed in another item?"
+msgid "Stock item has been installed in another item"
+msgstr "Ist dieses Teil in einem anderen verbaut?"
+
+#: templates/js/stock.html:489
 #, fuzzy
 #| msgid "StockItem has been allocated"
 msgid "Stock item has been rejected"
 msgstr "Lagerobjekt wurde zugewiesen"
 
-#: templates/js/stock.html:487
+#: templates/js/stock.html:493
 #, fuzzy
 #| msgid "StockItem is lost"
 msgid "Stock item is lost"
 msgstr "Lagerobjekt verloren"
 
-#: templates/js/stock.html:491 templates/js/table_filters.html:60
+#: templates/js/stock.html:497 templates/js/table_filters.html:60
 #, fuzzy
 #| msgid "Delete"
 msgid "Depleted"
 msgstr "Löschen"
 
-#: templates/js/stock.html:516
+#: templates/js/stock.html:522
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Installed in Stock Item "
 msgstr "In Lagerobjekt installiert"
 
-#: templates/js/stock.html:699
+#: templates/js/stock.html:530
+#, fuzzy
+#| msgid "Item assigned to customer?"
+msgid "Assigned to sales order"
+msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
+
+#: templates/js/stock.html:708
 msgid "No user information"
 msgstr "Keine Benutzerinformation"
 
-#: templates/js/stock.html:783
+#: templates/js/stock.html:792
 msgid "Create New Part"
 msgstr "Neues Teil anlegen"
 
-#: templates/js/stock.html:795
+#: templates/js/stock.html:804
 msgid "Create New Location"
 msgstr "Neuen Standort anlegen"
 
+#: templates/js/stock.html:903
+#, fuzzy
+#| msgid "Serial Number"
+msgid "Serial"
+msgstr "Seriennummer"
+
+#: templates/js/stock.html:996 templates/js/table_filters.html:70
+#, fuzzy
+#| msgid "Installed In"
+msgid "Installed"
+msgstr "Installiert in"
+
+#: templates/js/stock.html:1021
+#, fuzzy
+#| msgid "Installed In"
+msgid "Install item"
+msgstr "Installiert in"
+
 #: templates/js/table_filters.html:19 templates/js/table_filters.html:80
 #, fuzzy
 #| msgid "Serialize Stock"
@@ -4689,12 +4816,6 @@ msgstr "Objekt löschen wenn Lagerbestand aufgebraucht"
 msgid "Show items which are in stock"
 msgstr ""
 
-#: templates/js/table_filters.html:70
-#, fuzzy
-#| msgid "Installed In"
-msgid "Installed"
-msgstr "Installiert in"
-
 #: templates/js/table_filters.html:71
 #, fuzzy
 #| msgid "Is this item installed in another item?"
@@ -4737,19 +4858,29 @@ msgstr "Unterkategorien einschließen"
 msgid "Include parts in subcategories"
 msgstr "Teile in Unterkategorien einschließen"
 
+#: templates/js/table_filters.html:178
+msgid "Has IPN"
+msgstr ""
+
 #: templates/js/table_filters.html:179
+#, fuzzy
+#| msgid "Internal Part Number"
+msgid "Part has internal part number"
+msgstr "Interne Teilenummer"
+
+#: templates/js/table_filters.html:184
 msgid "Show active parts"
 msgstr "Aktive Teile anzeigen"
 
-#: templates/js/table_filters.html:187
+#: templates/js/table_filters.html:192
 msgid "Stock available"
 msgstr "Bestand verfügbar"
 
-#: templates/js/table_filters.html:203
+#: templates/js/table_filters.html:208
 msgid "Starred"
 msgstr "Favorit"
 
-#: templates/js/table_filters.html:215
+#: templates/js/table_filters.html:220
 msgid "Purchasable"
 msgstr "Käuflich"
 
@@ -4793,60 +4924,68 @@ msgstr "Statistiken"
 msgid "Search"
 msgstr "Suche"
 
-#: templates/stock_table.html:5
+#: templates/stock_table.html:6
 #, fuzzy
 #| msgid "Edit Stock Location"
 msgid "Export Stock Information"
 msgstr "Lagerobjekt-Standort bearbeiten"
 
-#: templates/stock_table.html:12
+#: templates/stock_table.html:13
 #, fuzzy
 #| msgid "Added stock to {n} items"
 msgid "Add to selected stock items"
 msgstr "Vorrat zu {n} Lagerobjekten hinzugefügt"
 
-#: templates/stock_table.html:13
+#: templates/stock_table.html:14
 #, fuzzy
 #| msgid "Remove selected BOM items"
 msgid "Remove from selected stock items"
 msgstr "Ausgewählte Stücklistenpositionen entfernen"
 
-#: templates/stock_table.html:14
+#: templates/stock_table.html:15
 #, fuzzy
 #| msgid "Delete Stock Item"
 msgid "Stocktake selected stock items"
 msgstr "Lagerobjekt löschen"
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:16
 #, fuzzy
 #| msgid "Delete Stock Item"
 msgid "Move selected stock items"
 msgstr "Lagerobjekt löschen"
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:16
 msgid "Move stock"
 msgstr "Bestand bewegen"
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:17
 #, fuzzy
 #| msgid "Remove selected BOM items"
 msgid "Order selected items"
 msgstr "Ausgewählte Stücklistenpositionen entfernen"
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:17
 msgid "Order stock"
 msgstr "Bestand bestellen"
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:18
 #, fuzzy
 #| msgid "Delete line item"
 msgid "Delete selected items"
 msgstr "Position löschen"
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:18
 msgid "Delete Stock"
 msgstr "Bestand löschen"
 
+#~ msgid "Belongs To"
+#~ msgstr "Gehört zu"
+
+#, fuzzy
+#~| msgid "Added stock to {n} items"
+#~ msgid "Uninstall selected stock items"
+#~ msgstr "Vorrat zu {n} Lagerobjekten hinzugefügt"
+
 #~ msgid "Order Multiple"
 #~ msgstr "Bestellvielfaches"
 
diff --git a/InvenTree/locale/en/LC_MESSAGES/django.po b/InvenTree/locale/en/LC_MESSAGES/django.po
index d4a3d7ceb6..0b5425db6e 100644
--- a/InvenTree/locale/en/LC_MESSAGES/django.po
+++ b/InvenTree/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-09-28 12:03+0000\n"
+"POT-Creation-Date: 2020-10-04 14:02+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -86,7 +86,7 @@ msgstr ""
 msgid "File comment"
 msgstr ""
 
-#: InvenTree/models.py:68 templates/js/stock.html:690
+#: InvenTree/models.py:68 templates/js/stock.html:699
 msgid "User"
 msgstr ""
 
@@ -99,19 +99,19 @@ msgstr ""
 msgid "Description (optional)"
 msgstr ""
 
-#: InvenTree/settings.py:341
+#: InvenTree/settings.py:342
 msgid "English"
 msgstr ""
 
-#: InvenTree/settings.py:342
+#: InvenTree/settings.py:343
 msgid "German"
 msgstr ""
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:344
 msgid "French"
 msgstr ""
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:345
 msgid "Polish"
 msgstr ""
 
@@ -143,7 +143,8 @@ msgstr ""
 msgid "Returned"
 msgstr ""
 
-#: InvenTree/status_codes.py:136 order/templates/order/sales_order_base.html:98
+#: InvenTree/status_codes.py:136
+#: order/templates/order/sales_order_base.html:103
 msgid "Shipped"
 msgstr ""
 
@@ -198,7 +199,7 @@ msgstr ""
 msgid "Overage must be an integer value or a percentage"
 msgstr ""
 
-#: InvenTree/views.py:639
+#: InvenTree/views.py:661
 msgid "Database Statistics"
 msgstr ""
 
@@ -250,7 +251,7 @@ msgstr ""
 msgid "Serial numbers"
 msgstr ""
 
-#: build/forms.py:64 stock/forms.py:107
+#: build/forms.py:64 stock/forms.py:111
 msgid "Enter unique serial numbers (or leave blank)"
 msgstr ""
 
@@ -262,7 +263,7 @@ msgstr ""
 msgid "Build quantity must be integer value for trackable parts"
 msgstr ""
 
-#: build/models.py:73 build/templates/build/build_base.html:65
+#: build/models.py:73 build/templates/build/build_base.html:70
 msgid "Build Title"
 msgstr ""
 
@@ -270,7 +271,7 @@ msgstr ""
 msgid "Brief description of the build"
 msgstr ""
 
-#: build/models.py:84 build/templates/build/build_base.html:86
+#: build/models.py:84 build/templates/build/build_base.html:91
 msgid "Parent Build"
 msgstr ""
 
@@ -280,18 +281,17 @@ msgstr ""
 
 #: build/models.py:90 build/templates/build/allocate.html:329
 #: build/templates/build/auto_allocate.html:19
-#: build/templates/build/build_base.html:70
+#: build/templates/build/build_base.html:75
 #: build/templates/build/detail.html:22 order/models.py:501
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:147
-#: order/templates/order/receive_parts.html:19 part/models.py:241
+#: order/templates/order/receive_parts.html:19 part/models.py:293
 #: part/templates/part/part_app_base.html:7
-#: part/templates/part/set_category.html:13
-#: stock/templates/stock/item_installed.html:60
-#: templates/InvenTree/search.html:123 templates/js/barcode.html:336
-#: templates/js/bom.html:124 templates/js/build.html:47
-#: templates/js/company.html:137 templates/js/part.html:223
-#: templates/js/stock.html:421
+#: part/templates/part/set_category.html:13 templates/InvenTree/search.html:133
+#: templates/js/barcode.html:336 templates/js/bom.html:124
+#: templates/js/build.html:47 templates/js/company.html:137
+#: templates/js/part.html:184 templates/js/part.html:289
+#: templates/js/stock.html:421 templates/js/stock.html:977
 msgid "Part"
 msgstr ""
 
@@ -325,7 +325,7 @@ msgstr ""
 msgid "Number of parts to build"
 msgstr ""
 
-#: build/models.py:128 part/templates/part/part_base.html:142
+#: build/models.py:128 part/templates/part/part_base.html:145
 msgid "Build Status"
 msgstr ""
 
@@ -333,7 +333,7 @@ msgstr ""
 msgid "Build status code"
 msgstr ""
 
-#: build/models.py:136 stock/models.py:371
+#: build/models.py:136 stock/models.py:387
 msgid "Batch Code"
 msgstr ""
 
@@ -344,12 +344,12 @@ msgstr ""
 #: build/models.py:155 build/templates/build/detail.html:55
 #: company/templates/company/supplier_part_base.html:60
 #: company/templates/company/supplier_part_detail.html:24
-#: part/templates/part/detail.html:80 part/templates/part/part_base.html:89
-#: stock/models.py:365 stock/templates/stock/item_base.html:232
+#: part/templates/part/detail.html:80 part/templates/part/part_base.html:92
+#: stock/models.py:381 stock/templates/stock/item_base.html:244
 msgid "External Link"
 msgstr ""
 
-#: build/models.py:156 stock/models.py:367
+#: build/models.py:156 stock/models.py:383
 msgid "Link to external URL"
 msgstr ""
 
@@ -357,10 +357,10 @@ msgstr ""
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:15
 #: order/templates/order/purchase_order_detail.html:202
 #: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:67
-#: stock/forms.py:281 stock/forms.py:309 stock/models.py:433
-#: stock/models.py:1353 stock/templates/stock/tabs.html:26
-#: templates/js/barcode.html:391 templates/js/bom.html:219
-#: templates/js/stock.html:116 templates/js/stock.html:534
+#: stock/forms.py:306 stock/forms.py:338 stock/forms.py:366 stock/models.py:453
+#: stock/models.py:1404 stock/templates/stock/tabs.html:26
+#: templates/js/barcode.html:391 templates/js/bom.html:223
+#: templates/js/stock.html:116 templates/js/stock.html:543
 msgid "Notes"
 msgstr ""
 
@@ -404,7 +404,7 @@ msgstr ""
 
 #: build/templates/build/allocate.html:17
 #: company/templates/company/detail_part.html:18 order/views.py:779
-#: part/templates/part/category.html:107
+#: part/templates/part/category.html:112
 msgid "Order Parts"
 msgstr ""
 
@@ -420,24 +420,24 @@ msgstr ""
 msgid "Unallocate"
 msgstr ""
 
-#: build/templates/build/allocate.html:87 templates/stock_table.html:8
+#: build/templates/build/allocate.html:87 templates/stock_table.html:9
 msgid "New Stock Item"
 msgstr ""
 
-#: build/templates/build/allocate.html:88 stock/views.py:1327
+#: build/templates/build/allocate.html:88 stock/views.py:1428
 msgid "Create new Stock Item"
 msgstr ""
 
 #: build/templates/build/allocate.html:170
 #: order/templates/order/sales_order_detail.html:68
-#: order/templates/order/sales_order_detail.html:150 stock/models.py:359
-#: stock/templates/stock/item_base.html:148
+#: order/templates/order/sales_order_detail.html:150 stock/models.py:375
+#: stock/templates/stock/item_base.html:156
 msgid "Serial Number"
 msgstr ""
 
 #: build/templates/build/allocate.html:172
 #: build/templates/build/auto_allocate.html:20
-#: build/templates/build/build_base.html:75
+#: build/templates/build/build_base.html:80
 #: build/templates/build/detail.html:27
 #: company/templates/company/supplier_part_pricing.html:71
 #: order/templates/order/order_wizard/select_parts.html:32
@@ -446,22 +446,22 @@ msgstr ""
 #: order/templates/order/sales_order_detail.html:152
 #: part/templates/part/allocation.html:16
 #: part/templates/part/allocation.html:49
-#: part/templates/part/sale_prices.html:80
+#: part/templates/part/sale_prices.html:80 stock/forms.py:297
 #: stock/templates/stock/item_base.html:26
 #: stock/templates/stock/item_base.html:32
-#: stock/templates/stock/item_base.html:154
+#: stock/templates/stock/item_base.html:162
 #: stock/templates/stock/stock_adjust.html:18 templates/js/barcode.html:338
 #: templates/js/bom.html:162 templates/js/build.html:58
-#: templates/js/stock.html:681
+#: templates/js/stock.html:690 templates/js/stock.html:905
 msgid "Quantity"
 msgstr ""
 
 #: build/templates/build/allocate.html:186
-#: build/templates/build/auto_allocate.html:21 stock/forms.py:279
-#: stock/templates/stock/item_base.html:186
+#: build/templates/build/auto_allocate.html:21 stock/forms.py:336
+#: stock/templates/stock/item_base.html:198
 #: stock/templates/stock/stock_adjust.html:17
-#: templates/InvenTree/search.html:173 templates/js/barcode.html:337
-#: templates/js/stock.html:512
+#: templates/InvenTree/search.html:183 templates/js/barcode.html:337
+#: templates/js/stock.html:518
 msgid "Location"
 msgstr ""
 
@@ -475,7 +475,7 @@ msgstr ""
 msgid "Delete stock allocation"
 msgstr ""
 
-#: build/templates/build/allocate.html:238 templates/js/bom.html:330
+#: build/templates/build/allocate.html:238 templates/js/bom.html:334
 msgid "No BOM items found"
 msgstr ""
 
@@ -484,12 +484,12 @@ msgstr ""
 #: company/templates/company/supplier_part_detail.html:27
 #: order/templates/order/purchase_order_detail.html:159
 #: part/templates/part/detail.html:51 part/templates/part/set_category.html:14
-#: stock/templates/stock/item_installed.html:83
-#: templates/InvenTree/search.html:137 templates/js/bom.html:147
+#: templates/InvenTree/search.html:147 templates/js/bom.html:147
 #: templates/js/company.html:56 templates/js/order.html:159
 #: templates/js/order.html:234 templates/js/part.html:120
-#: templates/js/part.html:279 templates/js/part.html:460
-#: templates/js/stock.html:444 templates/js/stock.html:662
+#: templates/js/part.html:203 templates/js/part.html:345
+#: templates/js/part.html:526 templates/js/stock.html:444
+#: templates/js/stock.html:671
 msgid "Description"
 msgstr ""
 
@@ -499,8 +499,8 @@ msgstr ""
 msgid "Reference"
 msgstr ""
 
-#: build/templates/build/allocate.html:347 part/models.py:1348
-#: templates/js/part.html:464 templates/js/table_filters.html:121
+#: build/templates/build/allocate.html:347 part/models.py:1401
+#: templates/js/part.html:530 templates/js/table_filters.html:121
 msgid "Required"
 msgstr ""
 
@@ -547,7 +547,7 @@ msgstr ""
 #: build/templates/build/build_base.html:8
 #: build/templates/build/build_base.html:34
 #: build/templates/build/complete.html:6
-#: stock/templates/stock/item_base.html:211 templates/js/build.html:39
+#: stock/templates/stock/item_base.html:223 templates/js/build.html:39
 #: templates/navbar.html:20
 msgid "Build"
 msgstr ""
@@ -560,40 +560,49 @@ msgstr ""
 msgid "This build is a child of Build"
 msgstr ""
 
-#: build/templates/build/build_base.html:61 build/templates/build/detail.html:9
+#: build/templates/build/build_base.html:39
+#: company/templates/company/company_base.html:27
+#: order/templates/order/order_base.html:28
+#: order/templates/order/sales_order_base.html:38
+#: part/templates/part/category.html:13 part/templates/part/part_base.html:32
+#: stock/templates/stock/item_base.html:69
+#: stock/templates/stock/location.html:12
+msgid "Admin view"
+msgstr ""
+
+#: build/templates/build/build_base.html:66 build/templates/build/detail.html:9
 msgid "Build Details"
 msgstr ""
 
-#: build/templates/build/build_base.html:80
+#: build/templates/build/build_base.html:85
 #: build/templates/build/detail.html:42
 #: order/templates/order/receive_parts.html:24
-#: stock/templates/stock/item_base.html:264
-#: stock/templates/stock/item_installed.html:111
-#: templates/InvenTree/search.html:165 templates/js/barcode.html:42
-#: templates/js/build.html:63 templates/js/order.html:164
-#: templates/js/order.html:239 templates/js/stock.html:499
+#: stock/templates/stock/item_base.html:276 templates/InvenTree/search.html:175
+#: templates/js/barcode.html:42 templates/js/build.html:63
+#: templates/js/order.html:164 templates/js/order.html:239
+#: templates/js/stock.html:505 templates/js/stock.html:913
 msgid "Status"
 msgstr ""
 
-#: build/templates/build/build_base.html:93 order/models.py:499
+#: build/templates/build/build_base.html:98 order/models.py:499
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
 #: order/templates/order/sales_order_ship.html:25
 #: part/templates/part/allocation.html:27
-#: stock/templates/stock/item_base.html:174 templates/js/order.html:213
+#: stock/templates/stock/item_base.html:186 templates/js/order.html:213
 msgid "Sales Order"
 msgstr ""
 
-#: build/templates/build/build_base.html:99
+#: build/templates/build/build_base.html:104
 msgid "BOM Price"
 msgstr ""
 
-#: build/templates/build/build_base.html:104
+#: build/templates/build/build_base.html:109
 msgid "BOM pricing is incomplete"
 msgstr ""
 
-#: build/templates/build/build_base.html:107
+#: build/templates/build/build_base.html:112
 msgid "No pricing information"
 msgstr ""
 
@@ -648,15 +657,15 @@ msgid "Stock can be taken from any available location."
 msgstr ""
 
 #: build/templates/build/detail.html:48
-#: stock/templates/stock/item_base.html:204
-#: stock/templates/stock/item_installed.html:119 templates/js/stock.html:507
-#: templates/js/table_filters.html:34 templates/js/table_filters.html:100
+#: stock/templates/stock/item_base.html:216 templates/js/stock.html:513
+#: templates/js/stock.html:920 templates/js/table_filters.html:34
+#: templates/js/table_filters.html:100
 msgid "Batch"
 msgstr ""
 
 #: build/templates/build/detail.html:61
-#: order/templates/order/order_base.html:93
-#: order/templates/order/sales_order_base.html:92 templates/js/build.html:71
+#: order/templates/order/order_base.html:98
+#: order/templates/order/sales_order_base.html:97 templates/js/build.html:71
 msgid "Created"
 msgstr ""
 
@@ -769,7 +778,7 @@ msgstr ""
 msgid "Invalid location selected"
 msgstr ""
 
-#: build/views.py:296 stock/views.py:1520
+#: build/views.py:296 stock/views.py:1621
 #, python-brace-format
 msgid "The following serial numbers already exist: ({sn})"
 msgstr ""
@@ -878,7 +887,7 @@ msgstr ""
 msgid "Description of the company"
 msgstr ""
 
-#: company/models.py:91 company/templates/company/company_base.html:48
+#: company/models.py:91 company/templates/company/company_base.html:53
 #: templates/js/company.html:61
 msgid "Website"
 msgstr ""
@@ -887,7 +896,7 @@ msgstr ""
 msgid "Company website URL"
 msgstr ""
 
-#: company/models.py:94 company/templates/company/company_base.html:55
+#: company/models.py:94 company/templates/company/company_base.html:60
 msgid "Address"
 msgstr ""
 
@@ -903,7 +912,7 @@ msgstr ""
 msgid "Contact phone number"
 msgstr ""
 
-#: company/models.py:101 company/templates/company/company_base.html:69
+#: company/models.py:101 company/templates/company/company_base.html:74
 msgid "Email"
 msgstr ""
 
@@ -911,7 +920,7 @@ msgstr ""
 msgid "Contact email address"
 msgstr ""
 
-#: company/models.py:104 company/templates/company/company_base.html:76
+#: company/models.py:104 company/templates/company/company_base.html:81
 msgid "Contact"
 msgstr ""
 
@@ -935,8 +944,8 @@ msgstr ""
 msgid "Does this company manufacture parts?"
 msgstr ""
 
-#: company/models.py:279 stock/models.py:319
-#: stock/templates/stock/item_base.html:140
+#: company/models.py:279 stock/models.py:335
+#: stock/templates/stock/item_base.html:148
 msgid "Base Part"
 msgstr ""
 
@@ -986,12 +995,12 @@ msgstr ""
 msgid "Company"
 msgstr ""
 
-#: company/templates/company/company_base.html:42
+#: company/templates/company/company_base.html:47
 #: company/templates/company/detail.html:8
 msgid "Company Details"
 msgstr ""
 
-#: company/templates/company/company_base.html:62
+#: company/templates/company/company_base.html:67
 msgid "Phone"
 msgstr ""
 
@@ -1005,16 +1014,16 @@ msgstr ""
 #: company/templates/company/detail.html:21
 #: company/templates/company/supplier_part_base.html:66
 #: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:74
+#: order/templates/order/order_base.html:79
 #: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
-#: stock/templates/stock/item_base.html:239 templates/js/company.html:48
+#: stock/templates/stock/item_base.html:251 templates/js/company.html:48
 #: templates/js/company.html:162 templates/js/order.html:146
 msgid "Supplier"
 msgstr ""
 
 #: company/templates/company/detail.html:26
-#: order/templates/order/sales_order_base.html:73 stock/models.py:354
-#: stock/models.py:355 stock/templates/stock/item_base.html:161
+#: order/templates/order/sales_order_base.html:78 stock/models.py:370
+#: stock/models.py:371 stock/templates/stock/item_base.html:169
 #: templates/js/company.html:40 templates/js/order.html:221
 msgid "Customer"
 msgstr ""
@@ -1030,18 +1039,18 @@ msgstr ""
 
 #: company/templates/company/detail_part.html:13
 #: order/templates/order/purchase_order_detail.html:67
-#: part/templates/part/supplier.html:13 templates/js/stock.html:788
+#: part/templates/part/supplier.html:13 templates/js/stock.html:797
 msgid "New Supplier Part"
 msgstr ""
 
 #: company/templates/company/detail_part.html:15
-#: part/templates/part/category.html:104 part/templates/part/supplier.html:15
-#: stock/templates/stock/item_installed.html:16 templates/stock_table.html:10
+#: part/templates/part/category.html:109 part/templates/part/supplier.html:15
+#: templates/stock_table.html:11
 msgid "Options"
 msgstr ""
 
 #: company/templates/company/detail_part.html:18
-#: part/templates/part/category.html:107
+#: part/templates/part/category.html:112
 msgid "Order parts"
 msgstr ""
 
@@ -1054,7 +1063,7 @@ msgid "Delete Parts"
 msgstr ""
 
 #: company/templates/company/detail_part.html:43
-#: part/templates/part/category.html:102 templates/js/stock.html:782
+#: part/templates/part/category.html:107 templates/js/stock.html:791
 msgid "New Part"
 msgstr ""
 
@@ -1086,8 +1095,8 @@ msgstr ""
 
 #: company/templates/company/detail_stock.html:35
 #: company/templates/company/supplier_part_stock.html:33
-#: part/templates/part/category.html:101 part/templates/part/category.html:108
-#: part/templates/part/stock.html:51 templates/stock_table.html:5
+#: part/templates/part/category.html:106 part/templates/part/category.html:113
+#: part/templates/part/stock.html:51 templates/stock_table.html:6
 msgid "Export"
 msgstr ""
 
@@ -1143,8 +1152,8 @@ msgid "New Sales Order"
 msgstr ""
 
 #: company/templates/company/supplier_part_base.html:6
-#: company/templates/company/supplier_part_base.html:19 stock/models.py:328
-#: stock/templates/stock/item_base.html:244 templates/js/company.html:178
+#: company/templates/company/supplier_part_base.html:19 stock/models.py:344
+#: stock/templates/stock/item_base.html:256 templates/js/company.html:178
 msgid "Supplier Part"
 msgstr ""
 
@@ -1196,7 +1205,7 @@ msgid "Pricing Information"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2108
+#: part/templates/part/sale_prices.html:13 part/views.py:2149
 msgid "Add Price Break"
 msgstr ""
 
@@ -1206,7 +1215,7 @@ msgid "No price break information found"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:76
-#: part/templates/part/sale_prices.html:85 templates/js/bom.html:203
+#: part/templates/part/sale_prices.html:85 templates/js/bom.html:207
 msgid "Price"
 msgstr ""
 
@@ -1230,9 +1239,8 @@ msgstr ""
 
 #: company/templates/company/supplier_part_tabs.html:8
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
-#: stock/templates/stock/item_installed.html:91
-#: stock/templates/stock/location.html:12 templates/InvenTree/search.html:145
-#: templates/js/part.html:124 templates/js/part.html:306
+#: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
+#: templates/js/part.html:124 templates/js/part.html:372
 #: templates/js/stock.html:452 templates/navbar.html:19
 msgid "Stock"
 msgstr ""
@@ -1242,9 +1250,10 @@ msgid "Orders"
 msgstr ""
 
 #: company/templates/company/tabs.html:9
-#: order/templates/order/receive_parts.html:14 part/models.py:242
-#: part/templates/part/cat_link.html:7 part/templates/part/category.html:83
-#: templates/navbar.html:18 templates/stats.html:8 templates/stats.html:17
+#: order/templates/order/receive_parts.html:14 part/models.py:294
+#: part/templates/part/cat_link.html:7 part/templates/part/category.html:88
+#: part/templates/part/category_tabs.html:6 templates/navbar.html:18
+#: templates/stats.html:8 templates/stats.html:17
 msgid "Parts"
 msgstr ""
 
@@ -1313,7 +1322,7 @@ msgstr ""
 msgid "Edit Supplier Part"
 msgstr ""
 
-#: company/views.py:269 templates/js/stock.html:789
+#: company/views.py:269 templates/js/stock.html:798
 msgid "Create new Supplier Part"
 msgstr ""
 
@@ -1321,15 +1330,15 @@ msgstr ""
 msgid "Delete Supplier Part"
 msgstr ""
 
-#: company/views.py:404 part/views.py:2112
+#: company/views.py:404 part/views.py:2153
 msgid "Added new price break"
 msgstr ""
 
-#: company/views.py:441 part/views.py:2157
+#: company/views.py:441 part/views.py:2198
 msgid "Edit Price Break"
 msgstr ""
 
-#: company/views.py:456 part/views.py:2171
+#: company/views.py:456 part/views.py:2212
 msgid "Delete Price Break"
 msgstr ""
 
@@ -1366,11 +1375,11 @@ msgid "Mark order as complete"
 msgstr ""
 
 #: order/forms.py:46 order/forms.py:57
-#: order/templates/order/sales_order_base.html:49
+#: order/templates/order/sales_order_base.html:54
 msgid "Cancel order"
 msgstr ""
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:46
+#: order/forms.py:68 order/templates/order/sales_order_base.html:51
 msgid "Ship order"
 msgstr ""
 
@@ -1423,7 +1432,7 @@ msgid "Date order was completed"
 msgstr ""
 
 #: order/models.py:185 order/models.py:259 part/views.py:1304
-#: stock/models.py:239 stock/models.py:754
+#: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr ""
 
@@ -1461,7 +1470,7 @@ msgstr ""
 
 #: order/models.py:466 order/templates/order/order_base.html:9
 #: order/templates/order/order_base.html:23
-#: stock/templates/stock/item_base.html:218 templates/js/order.html:138
+#: stock/templates/stock/item_base.html:230 templates/js/order.html:138
 msgid "Purchase Order"
 msgstr ""
 
@@ -1503,32 +1512,32 @@ msgstr ""
 msgid "Are you sure you want to delete this attachment?"
 msgstr ""
 
-#: order/templates/order/order_base.html:59
-msgid "Purchase Order Details"
-msgstr ""
-
 #: order/templates/order/order_base.html:64
-#: order/templates/order/sales_order_base.html:63
-msgid "Order Reference"
+msgid "Purchase Order Details"
 msgstr ""
 
 #: order/templates/order/order_base.html:69
 #: order/templates/order/sales_order_base.html:68
+msgid "Order Reference"
+msgstr ""
+
+#: order/templates/order/order_base.html:74
+#: order/templates/order/sales_order_base.html:73
 msgid "Order Status"
 msgstr ""
 
-#: order/templates/order/order_base.html:80 templates/js/order.html:153
+#: order/templates/order/order_base.html:85 templates/js/order.html:153
 msgid "Supplier Reference"
 msgstr ""
 
-#: order/templates/order/order_base.html:99
+#: order/templates/order/order_base.html:104
 msgid "Issued"
 msgstr ""
 
-#: order/templates/order/order_base.html:106
+#: order/templates/order/order_base.html:111
 #: order/templates/order/purchase_order_detail.html:182
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:105
+#: order/templates/order/sales_order_base.html:110
 msgid "Received"
 msgstr ""
 
@@ -1607,14 +1616,14 @@ msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:38
 #: order/templates/order/purchase_order_detail.html:118
-#: part/templates/part/category.html:153 part/templates/part/category.html:194
-#: templates/js/stock.html:794
+#: part/templates/part/category.html:161 part/templates/part/category.html:202
+#: templates/js/stock.html:803
 msgid "New Location"
 msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:39
 #: order/templates/order/purchase_order_detail.html:119
-#: stock/templates/stock/location.html:16
+#: stock/templates/stock/location.html:21
 msgid "Create new stock location"
 msgstr ""
 
@@ -1649,7 +1658,7 @@ msgid "Select parts to receive against this order"
 msgstr ""
 
 #: order/templates/order/receive_parts.html:21
-#: part/templates/part/part_base.html:132 templates/js/part.html:322
+#: part/templates/part/part_base.html:135 templates/js/part.html:388
 msgid "On Order"
 msgstr ""
 
@@ -1665,15 +1674,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:42
+#: order/templates/order/sales_order_base.html:47
 msgid "Packing List"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:58
+#: order/templates/order/sales_order_base.html:63
 msgid "Sales Order Details"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:79 templates/js/order.html:228
+#: order/templates/order/sales_order_base.html:84 templates/js/order.html:228
 msgid "Customer Reference"
 msgstr ""
 
@@ -1881,12 +1890,12 @@ msgstr ""
 msgid "Remove allocation"
 msgstr ""
 
-#: part/bom.py:138 part/templates/part/category.html:50
+#: part/bom.py:138 part/templates/part/category.html:55
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr ""
 
-#: part/bom.py:139 part/templates/part/part_base.html:105
+#: part/bom.py:139 part/templates/part/part_base.html:108
 msgid "Available Stock"
 msgstr ""
 
@@ -1903,11 +1912,11 @@ msgstr ""
 msgid "Error reading BOM file (incorrect row size)"
 msgstr ""
 
-#: part/forms.py:57 stock/forms.py:250
+#: part/forms.py:57 stock/forms.py:254
 msgid "File Format"
 msgstr ""
 
-#: part/forms.py:57 stock/forms.py:250
+#: part/forms.py:57 stock/forms.py:254
 msgid "Select output file format"
 msgstr ""
 
@@ -1983,11 +1992,11 @@ msgstr ""
 msgid "Confirm part creation"
 msgstr ""
 
-#: part/forms.py:247
+#: part/forms.py:248
 msgid "Input quantity for price calculation"
 msgstr ""
 
-#: part/forms.py:250
+#: part/forms.py:251
 msgid "Select currency for price calculation"
 msgstr ""
 
@@ -2003,222 +2012,226 @@ msgstr ""
 msgid "Part Category"
 msgstr ""
 
-#: part/models.py:76 part/templates/part/category.html:13
-#: part/templates/part/category.html:78 templates/stats.html:12
+#: part/models.py:76 part/templates/part/category.html:18
+#: part/templates/part/category.html:83 templates/stats.html:12
 msgid "Part Categories"
 msgstr ""
 
-#: part/models.py:293 part/models.py:303
+#: part/models.py:345 part/models.py:355
 #, python-brace-format
 msgid "Part '{p1}' is  used in BOM for '{p2}' (recursive)"
 msgstr ""
 
-#: part/models.py:383
+#: part/models.py:435
 msgid "Next available serial numbers are"
 msgstr ""
 
-#: part/models.py:387
+#: part/models.py:439
 msgid "Next available serial number is"
 msgstr ""
 
-#: part/models.py:392
+#: part/models.py:444
 msgid "Most recent serial number is"
 msgstr ""
 
-#: part/models.py:470
+#: part/models.py:522
 msgid "Part must be unique for name, IPN and revision"
 msgstr ""
 
-#: part/models.py:485 part/templates/part/detail.html:19
+#: part/models.py:537 part/templates/part/detail.html:19
 msgid "Part name"
 msgstr ""
 
-#: part/models.py:489
+#: part/models.py:541
 msgid "Is this part a template part?"
 msgstr ""
 
-#: part/models.py:498
+#: part/models.py:550
 msgid "Is this part a variant of another part?"
 msgstr ""
 
-#: part/models.py:500
+#: part/models.py:552
 msgid "Part description"
 msgstr ""
 
-#: part/models.py:502
+#: part/models.py:554
 msgid "Part keywords to improve visibility in search results"
 msgstr ""
 
-#: part/models.py:507
+#: part/models.py:559
 msgid "Part category"
 msgstr ""
 
-#: part/models.py:509
+#: part/models.py:561
 msgid "Internal Part Number"
 msgstr ""
 
-#: part/models.py:511
+#: part/models.py:563
 msgid "Part revision or version number"
 msgstr ""
 
-#: part/models.py:513
+#: part/models.py:565
 msgid "Link to extenal URL"
 msgstr ""
 
-#: part/models.py:525
+#: part/models.py:577
 msgid "Where is this item normally stored?"
 msgstr ""
 
-#: part/models.py:569
+#: part/models.py:621
 msgid "Default supplier part"
 msgstr ""
 
-#: part/models.py:572
+#: part/models.py:624
 msgid "Minimum allowed stock level"
 msgstr ""
 
-#: part/models.py:574
+#: part/models.py:626
 msgid "Stock keeping units for this part"
 msgstr ""
 
-#: part/models.py:576
+#: part/models.py:628
 msgid "Can this part be built from other parts?"
 msgstr ""
 
-#: part/models.py:578
+#: part/models.py:630
 msgid "Can this part be used to build other parts?"
 msgstr ""
 
-#: part/models.py:580
+#: part/models.py:632
 msgid "Does this part have tracking for unique items?"
 msgstr ""
 
-#: part/models.py:582
+#: part/models.py:634
 msgid "Can this part be purchased from external suppliers?"
 msgstr ""
 
-#: part/models.py:584
+#: part/models.py:636
 msgid "Can this part be sold to customers?"
 msgstr ""
 
-#: part/models.py:586
+#: part/models.py:638
 msgid "Is this part active?"
 msgstr ""
 
-#: part/models.py:588
+#: part/models.py:640
 msgid "Is this a virtual part, such as a software product or license?"
 msgstr ""
 
-#: part/models.py:590
+#: part/models.py:642
 msgid "Part notes - supports Markdown formatting"
 msgstr ""
 
-#: part/models.py:592
+#: part/models.py:644
 msgid "Stored BOM checksum"
 msgstr ""
 
-#: part/models.py:1300
+#: part/models.py:1353
 msgid "Test templates can only be created for trackable parts"
 msgstr ""
 
-#: part/models.py:1317
+#: part/models.py:1370
 msgid "Test with this name already exists for this part"
 msgstr ""
 
-#: part/models.py:1336 templates/js/part.html:455 templates/js/stock.html:92
+#: part/models.py:1389 templates/js/part.html:521 templates/js/stock.html:92
 msgid "Test Name"
 msgstr ""
 
-#: part/models.py:1337
+#: part/models.py:1390
 msgid "Enter a name for the test"
 msgstr ""
 
-#: part/models.py:1342
+#: part/models.py:1395
 msgid "Test Description"
 msgstr ""
 
-#: part/models.py:1343
+#: part/models.py:1396
 msgid "Enter description for this test"
 msgstr ""
 
-#: part/models.py:1349
+#: part/models.py:1402
 msgid "Is this test required to pass?"
 msgstr ""
 
-#: part/models.py:1354 templates/js/part.html:472
+#: part/models.py:1407 templates/js/part.html:538
 msgid "Requires Value"
 msgstr ""
 
-#: part/models.py:1355
+#: part/models.py:1408
 msgid "Does this test require a value when adding a test result?"
 msgstr ""
 
-#: part/models.py:1360 templates/js/part.html:479
+#: part/models.py:1413 templates/js/part.html:545
 msgid "Requires Attachment"
 msgstr ""
 
-#: part/models.py:1361
+#: part/models.py:1414
 msgid "Does this test require a file attachment when adding a test result?"
 msgstr ""
 
-#: part/models.py:1394
+#: part/models.py:1447
 msgid "Parameter template name must be unique"
 msgstr ""
 
-#: part/models.py:1399
+#: part/models.py:1452
 msgid "Parameter Name"
 msgstr ""
 
-#: part/models.py:1401
+#: part/models.py:1454
 msgid "Parameter Units"
 msgstr ""
 
-#: part/models.py:1427
+#: part/models.py:1480
 msgid "Parent Part"
 msgstr ""
 
-#: part/models.py:1429
+#: part/models.py:1482
 msgid "Parameter Template"
 msgstr ""
 
-#: part/models.py:1431
+#: part/models.py:1484
 msgid "Parameter Value"
 msgstr ""
 
-#: part/models.py:1467
+#: part/models.py:1521
 msgid "Select parent part"
 msgstr ""
 
-#: part/models.py:1475
+#: part/models.py:1529
 msgid "Select part to be used in BOM"
 msgstr ""
 
-#: part/models.py:1481
+#: part/models.py:1535
 msgid "BOM quantity for this BOM item"
 msgstr ""
 
-#: part/models.py:1484
+#: part/models.py:1537
+msgid "This BOM item is optional"
+msgstr ""
+
+#: part/models.py:1540
 msgid "Estimated build wastage quantity (absolute or percentage)"
 msgstr ""
 
-#: part/models.py:1487
+#: part/models.py:1543
 msgid "BOM item reference"
 msgstr ""
 
-#: part/models.py:1490
+#: part/models.py:1546
 msgid "BOM item notes"
 msgstr ""
 
-#: part/models.py:1492
+#: part/models.py:1548
 msgid "BOM line checksum"
 msgstr ""
 
-#: part/models.py:1556 part/views.py:1310 part/views.py:1362
-#: stock/models.py:229
+#: part/models.py:1612 part/views.py:1310 part/views.py:1362
+#: stock/models.py:231
 msgid "Quantity must be integer value for trackable parts"
 msgstr ""
 
-#: part/models.py:1565
+#: part/models.py:1621
 msgid "BOM Item"
 msgstr ""
 
@@ -2237,14 +2250,14 @@ msgstr ""
 #: part/templates/part/allocation.html:45
 #: stock/templates/stock/item_base.html:8
 #: stock/templates/stock/item_base.html:58
-#: stock/templates/stock/item_base.html:226
+#: stock/templates/stock/item_base.html:238
 #: stock/templates/stock/stock_adjust.html:16 templates/js/build.html:112
-#: templates/js/stock.html:651
+#: templates/js/stock.html:660 templates/js/stock.html:896
 msgid "Stock Item"
 msgstr ""
 
 #: part/templates/part/allocation.html:20
-#: stock/templates/stock/item_base.html:180
+#: stock/templates/stock/item_base.html:192
 msgid "Build Order"
 msgstr ""
 
@@ -2360,91 +2373,95 @@ msgstr ""
 msgid "Each part must already exist in the database"
 msgstr ""
 
-#: part/templates/part/category.html:14
+#: part/templates/part/category.html:19
 msgid "All parts"
 msgstr ""
 
-#: part/templates/part/category.html:18 part/views.py:1935
+#: part/templates/part/category.html:23 part/views.py:1976
 msgid "Create new part category"
 msgstr ""
 
-#: part/templates/part/category.html:22
+#: part/templates/part/category.html:27
 msgid "Edit part category"
 msgstr ""
 
-#: part/templates/part/category.html:25
+#: part/templates/part/category.html:30
 msgid "Delete part category"
 msgstr ""
 
-#: part/templates/part/category.html:34 part/templates/part/category.html:73
+#: part/templates/part/category.html:39 part/templates/part/category.html:78
 msgid "Category Details"
 msgstr ""
 
-#: part/templates/part/category.html:39
+#: part/templates/part/category.html:44
 msgid "Category Path"
 msgstr ""
 
-#: part/templates/part/category.html:44
+#: part/templates/part/category.html:49
 msgid "Category Description"
 msgstr ""
 
-#: part/templates/part/category.html:57 part/templates/part/detail.html:64
+#: part/templates/part/category.html:62 part/templates/part/detail.html:64
 msgid "Keywords"
 msgstr ""
 
-#: part/templates/part/category.html:63
+#: part/templates/part/category.html:68
 msgid "Subcategories"
 msgstr ""
 
-#: part/templates/part/category.html:68
+#: part/templates/part/category.html:73
 msgid "Parts (Including subcategories)"
 msgstr ""
 
-#: part/templates/part/category.html:101
+#: part/templates/part/category.html:106
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:102 part/views.py:491
+#: part/templates/part/category.html:107 part/views.py:491
 msgid "Create new part"
 msgstr ""
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:111
 msgid "Set category"
 msgstr ""
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:111
 msgid "Set Category"
 msgstr ""
 
-#: part/templates/part/category.html:108
+#: part/templates/part/category.html:113
 msgid "Export Data"
 msgstr ""
 
-#: part/templates/part/category.html:154
+#: part/templates/part/category.html:162
 msgid "Create new location"
 msgstr ""
 
-#: part/templates/part/category.html:159 part/templates/part/category.html:188
+#: part/templates/part/category.html:167 part/templates/part/category.html:196
 msgid "New Category"
 msgstr ""
 
-#: part/templates/part/category.html:160
+#: part/templates/part/category.html:168
 msgid "Create new category"
 msgstr ""
 
-#: part/templates/part/category.html:189
+#: part/templates/part/category.html:197
 msgid "Create new Part Category"
 msgstr ""
 
-#: part/templates/part/category.html:195 stock/views.py:1213
+#: part/templates/part/category.html:203 stock/views.py:1314
 msgid "Create new Stock Location"
 msgstr ""
 
+#: part/templates/part/category_tabs.html:9
+msgid "Parametric Table"
+msgstr ""
+
 #: part/templates/part/detail.html:9
 msgid "Part Details"
 msgstr ""
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:82
+#: part/templates/part/detail.html:25 part/templates/part/part_base.html:85
 #: templates/js/part.html:112
 msgid "IPN"
 msgstr ""
@@ -2466,7 +2483,7 @@ msgid "Variant Of"
 msgstr ""
 
 #: part/templates/part/detail.html:70 part/templates/part/set_category.html:15
-#: templates/js/part.html:293
+#: templates/js/part.html:359
 msgid "Category"
 msgstr ""
 
@@ -2506,8 +2523,8 @@ msgstr ""
 msgid "Part is not a virtual part"
 msgstr ""
 
-#: part/templates/part/detail.html:145 stock/forms.py:244
-#: templates/js/table_filters.html:183
+#: part/templates/part/detail.html:145 stock/forms.py:248
+#: templates/js/table_filters.html:188
 msgid "Template"
 msgstr ""
 
@@ -2519,7 +2536,7 @@ msgstr ""
 msgid "Part is not a template part"
 msgstr ""
 
-#: part/templates/part/detail.html:154 templates/js/table_filters.html:195
+#: part/templates/part/detail.html:154 templates/js/table_filters.html:200
 msgid "Assembly"
 msgstr ""
 
@@ -2531,7 +2548,7 @@ msgstr ""
 msgid "Part cannot be assembled from other parts"
 msgstr ""
 
-#: part/templates/part/detail.html:163 templates/js/table_filters.html:199
+#: part/templates/part/detail.html:163 templates/js/table_filters.html:204
 msgid "Component"
 msgstr ""
 
@@ -2543,7 +2560,7 @@ msgstr ""
 msgid "Part cannot be used in assemblies"
 msgstr ""
 
-#: part/templates/part/detail.html:172 templates/js/table_filters.html:211
+#: part/templates/part/detail.html:172 templates/js/table_filters.html:216
 msgid "Trackable"
 msgstr ""
 
@@ -2563,7 +2580,7 @@ msgstr ""
 msgid "Part can be purchased from external suppliers"
 msgstr ""
 
-#: part/templates/part/detail.html:190 templates/js/table_filters.html:207
+#: part/templates/part/detail.html:190 templates/js/table_filters.html:212
 msgid "Salable"
 msgstr ""
 
@@ -2575,7 +2592,7 @@ msgstr ""
 msgid "Part cannot be sold to customers"
 msgstr ""
 
-#: part/templates/part/detail.html:199 templates/js/table_filters.html:178
+#: part/templates/part/detail.html:199 templates/js/table_filters.html:183
 msgid "Active"
 msgstr ""
 
@@ -2607,7 +2624,7 @@ msgstr ""
 msgid "New Parameter"
 msgstr ""
 
-#: part/templates/part/params.html:21 stock/models.py:1340
+#: part/templates/part/params.html:21 stock/models.py:1391
 #: templates/js/stock.html:112
 msgid "Value"
 msgstr ""
@@ -2636,70 +2653,70 @@ msgstr ""
 msgid "This part is a variant of"
 msgstr ""
 
-#: part/templates/part/part_base.html:33 templates/js/company.html:153
-#: templates/js/part.html:270
+#: part/templates/part/part_base.html:36 templates/js/company.html:153
+#: templates/js/part.html:336
 msgid "Inactive"
 msgstr ""
 
-#: part/templates/part/part_base.html:40
+#: part/templates/part/part_base.html:43
 msgid "Star this part"
 msgstr ""
 
-#: part/templates/part/part_base.html:46
-#: stock/templates/stock/item_base.html:78
-#: stock/templates/stock/location.html:22
-msgid "Barcode actions"
-msgstr ""
-
-#: part/templates/part/part_base.html:48
-#: stock/templates/stock/item_base.html:80
-#: stock/templates/stock/location.html:24
-msgid "Show QR Code"
-msgstr ""
-
 #: part/templates/part/part_base.html:49
 #: stock/templates/stock/item_base.html:81
-#: stock/templates/stock/location.html:25
+#: stock/templates/stock/location.html:27
+msgid "Barcode actions"
+msgstr ""
+
+#: part/templates/part/part_base.html:51
+#: stock/templates/stock/item_base.html:83
+#: stock/templates/stock/location.html:29
+msgid "Show QR Code"
+msgstr ""
+
+#: part/templates/part/part_base.html:52
+#: stock/templates/stock/item_base.html:84
+#: stock/templates/stock/location.html:30
 msgid "Print Label"
 msgstr ""
 
-#: part/templates/part/part_base.html:53
+#: part/templates/part/part_base.html:56
 msgid "Show pricing information"
 msgstr ""
 
-#: part/templates/part/part_base.html:67
+#: part/templates/part/part_base.html:70
 msgid "Part actions"
 msgstr ""
 
-#: part/templates/part/part_base.html:69
+#: part/templates/part/part_base.html:72
 msgid "Duplicate part"
 msgstr ""
 
-#: part/templates/part/part_base.html:70
+#: part/templates/part/part_base.html:73
 msgid "Edit part"
 msgstr ""
 
-#: part/templates/part/part_base.html:72
+#: part/templates/part/part_base.html:75
 msgid "Delete part"
 msgstr ""
 
-#: part/templates/part/part_base.html:111 templates/js/table_filters.html:65
+#: part/templates/part/part_base.html:114 templates/js/table_filters.html:65
 msgid "In Stock"
 msgstr ""
 
-#: part/templates/part/part_base.html:118
+#: part/templates/part/part_base.html:121
 msgid "Allocated to Build Orders"
 msgstr ""
 
-#: part/templates/part/part_base.html:125
+#: part/templates/part/part_base.html:128
 msgid "Allocated to Sales Orders"
 msgstr ""
 
-#: part/templates/part/part_base.html:147
+#: part/templates/part/part_base.html:150
 msgid "Can Build"
 msgstr ""
 
-#: part/templates/part/part_base.html:153
+#: part/templates/part/part_base.html:156
 msgid "Underway"
 msgstr ""
 
@@ -2743,8 +2760,8 @@ msgstr ""
 msgid "Part Stock"
 msgstr ""
 
-#: part/templates/part/stock_count.html:7 templates/js/bom.html:193
-#: templates/js/part.html:330
+#: part/templates/part/stock_count.html:7 templates/js/bom.html:197
+#: templates/js/part.html:396
 msgid "No Stock"
 msgstr ""
 
@@ -2784,7 +2801,7 @@ msgstr ""
 msgid "Used In"
 msgstr ""
 
-#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:270
+#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:282
 msgid "Tests"
 msgstr ""
 
@@ -2961,27 +2978,27 @@ msgstr ""
 msgid "Delete Part Parameter"
 msgstr ""
 
-#: part/views.py:1886
+#: part/views.py:1927
 msgid "Edit Part Category"
 msgstr ""
 
-#: part/views.py:1921
+#: part/views.py:1962
 msgid "Delete Part Category"
 msgstr ""
 
-#: part/views.py:1927
+#: part/views.py:1968
 msgid "Part category was deleted"
 msgstr ""
 
-#: part/views.py:1986
+#: part/views.py:2027
 msgid "Create BOM item"
 msgstr ""
 
-#: part/views.py:2052
+#: part/views.py:2093
 msgid "Edit BOM item"
 msgstr ""
 
-#: part/views.py:2100
+#: part/views.py:2141
 msgid "Confim BOM item deletion"
 msgstr ""
 
@@ -3013,267 +3030,295 @@ msgstr ""
 msgid "Asset file description"
 msgstr ""
 
-#: stock/forms.py:187
+#: stock/forms.py:191
 msgid "Label"
 msgstr ""
 
-#: stock/forms.py:188 stock/forms.py:244
+#: stock/forms.py:192 stock/forms.py:248
 msgid "Select test report template"
 msgstr ""
 
-#: stock/forms.py:252
+#: stock/forms.py:256
 msgid "Include stock items in sub locations"
 msgstr ""
 
-#: stock/forms.py:279
+#: stock/forms.py:291
+msgid "Stock item to install"
+msgstr ""
+
+#: stock/forms.py:298
+msgid "Stock quantity to assign"
+msgstr ""
+
+#: stock/forms.py:326
+msgid "Must not exceed available quantity"
+msgstr ""
+
+#: stock/forms.py:336
 msgid "Destination location for uninstalled items"
 msgstr ""
 
-#: stock/forms.py:281
+#: stock/forms.py:338
 msgid "Add transaction note (optional)"
 msgstr ""
 
-#: stock/forms.py:283
+#: stock/forms.py:340
 msgid "Confirm uninstall"
 msgstr ""
 
-#: stock/forms.py:283
+#: stock/forms.py:340
 msgid "Confirm removal of installed stock items"
 msgstr ""
 
-#: stock/forms.py:307
+#: stock/forms.py:364
 msgid "Destination"
 msgstr ""
 
-#: stock/forms.py:307
+#: stock/forms.py:364
 msgid "Destination stock location"
 msgstr ""
 
-#: stock/forms.py:309
+#: stock/forms.py:366
 msgid "Add note (required)"
 msgstr ""
 
-#: stock/forms.py:313 stock/views.py:795 stock/views.py:992
+#: stock/forms.py:370 stock/views.py:895 stock/views.py:1092
 msgid "Confirm stock adjustment"
 msgstr ""
 
-#: stock/forms.py:313
+#: stock/forms.py:370
 msgid "Confirm movement of stock items"
 msgstr ""
 
-#: stock/forms.py:315
+#: stock/forms.py:372
 msgid "Set Default Location"
 msgstr ""
 
-#: stock/forms.py:315
+#: stock/forms.py:372
 msgid "Set the destination as the default location for selected parts"
 msgstr ""
 
-#: stock/models.py:210
+#: stock/models.py:212
 msgid "StockItem with this serial number already exists"
 msgstr ""
 
-#: stock/models.py:246
+#: stock/models.py:248
 #, python-brace-format
 msgid "Part type ('{pf}') must be {pe}"
 msgstr ""
 
-#: stock/models.py:256 stock/models.py:265
+#: stock/models.py:258 stock/models.py:267
 msgid "Quantity must be 1 for item with a serial number"
 msgstr ""
 
-#: stock/models.py:257
+#: stock/models.py:259
 msgid "Serial number cannot be set if quantity greater than 1"
 msgstr ""
 
-#: stock/models.py:278
+#: stock/models.py:281
 msgid "Item cannot belong to itself"
 msgstr ""
 
-#: stock/models.py:311
+#: stock/models.py:287
+msgid "Item must have a build reference if is_building=True"
+msgstr ""
+
+#: stock/models.py:294
+msgid "Build reference does not point to the same part object"
+msgstr ""
+
+#: stock/models.py:327
 msgid "Parent Stock Item"
 msgstr ""
 
-#: stock/models.py:320
+#: stock/models.py:336
 msgid "Base part"
 msgstr ""
 
-#: stock/models.py:329
+#: stock/models.py:345
 msgid "Select a matching supplier part for this stock item"
 msgstr ""
 
-#: stock/models.py:334 stock/templates/stock/stock_app_base.html:7
+#: stock/models.py:350 stock/templates/stock/stock_app_base.html:7
 msgid "Stock Location"
 msgstr ""
 
-#: stock/models.py:337
+#: stock/models.py:353
 msgid "Where is this stock item located?"
 msgstr ""
 
-#: stock/models.py:342
+#: stock/models.py:358 stock/templates/stock/item_base.html:177
 msgid "Installed In"
 msgstr ""
 
-#: stock/models.py:345
+#: stock/models.py:361
 msgid "Is this item installed in another item?"
 msgstr ""
 
-#: stock/models.py:361
+#: stock/models.py:377
 msgid "Serial number for this item"
 msgstr ""
 
-#: stock/models.py:373
+#: stock/models.py:389
 msgid "Batch code for this stock item"
 msgstr ""
 
-#: stock/models.py:377
+#: stock/models.py:393
 msgid "Stock Quantity"
 msgstr ""
 
-#: stock/models.py:386
+#: stock/models.py:402
 msgid "Source Build"
 msgstr ""
 
-#: stock/models.py:388
+#: stock/models.py:404
 msgid "Build for this stock item"
 msgstr ""
 
-#: stock/models.py:395
+#: stock/models.py:415
 msgid "Source Purchase Order"
 msgstr ""
 
-#: stock/models.py:398
+#: stock/models.py:418
 msgid "Purchase order for this stock item"
 msgstr ""
 
-#: stock/models.py:404
+#: stock/models.py:424
 msgid "Destination Sales Order"
 msgstr ""
 
-#: stock/models.py:411
+#: stock/models.py:431
 msgid "Destination Build Order"
 msgstr ""
 
-#: stock/models.py:424
+#: stock/models.py:444
 msgid "Delete this Stock Item when stock is depleted"
 msgstr ""
 
-#: stock/models.py:434 stock/templates/stock/item_notes.html:14
+#: stock/models.py:454 stock/templates/stock/item_notes.html:14
 #: stock/templates/stock/item_notes.html:30
 msgid "Stock Item Notes"
 msgstr ""
 
-#: stock/models.py:485
+#: stock/models.py:505
 msgid "Assigned to Customer"
 msgstr ""
 
-#: stock/models.py:487
+#: stock/models.py:507
 msgid "Manually assigned to customer"
 msgstr ""
 
-#: stock/models.py:500
+#: stock/models.py:520
 msgid "Returned from customer"
 msgstr ""
 
-#: stock/models.py:502
+#: stock/models.py:522
 msgid "Returned to location"
 msgstr ""
 
-#: stock/models.py:626
-msgid "Installed in stock item"
+#: stock/models.py:650
+msgid "Installed into stock item"
 msgstr ""
 
-#: stock/models.py:655
+#: stock/models.py:658
+msgid "Installed stock item"
+msgstr ""
+
+#: stock/models.py:682
+msgid "Uninstalled stock item"
+msgstr ""
+
+#: stock/models.py:701
 msgid "Uninstalled into location"
 msgstr ""
 
-#: stock/models.py:745
+#: stock/models.py:796
 msgid "Part is not set as trackable"
 msgstr ""
 
-#: stock/models.py:751
+#: stock/models.py:802
 msgid "Quantity must be integer"
 msgstr ""
 
-#: stock/models.py:757
+#: stock/models.py:808
 #, python-brace-format
 msgid "Quantity must not exceed available stock quantity ({n})"
 msgstr ""
 
-#: stock/models.py:760
+#: stock/models.py:811
 msgid "Serial numbers must be a list of integers"
 msgstr ""
 
-#: stock/models.py:763
+#: stock/models.py:814
 msgid "Quantity does not match serial numbers"
 msgstr ""
 
-#: stock/models.py:773
+#: stock/models.py:824
 msgid "Serial numbers already exist: "
 msgstr ""
 
-#: stock/models.py:798
+#: stock/models.py:849
 msgid "Add serial number"
 msgstr ""
 
-#: stock/models.py:801
+#: stock/models.py:852
 #, python-brace-format
 msgid "Serialized {n} items"
 msgstr ""
 
-#: stock/models.py:912
+#: stock/models.py:963
 msgid "StockItem cannot be moved as it is not in stock"
 msgstr ""
 
-#: stock/models.py:1241
+#: stock/models.py:1292
 msgid "Tracking entry title"
 msgstr ""
 
-#: stock/models.py:1243
+#: stock/models.py:1294
 msgid "Entry notes"
 msgstr ""
 
-#: stock/models.py:1245
+#: stock/models.py:1296
 msgid "Link to external page for further information"
 msgstr ""
 
-#: stock/models.py:1305
+#: stock/models.py:1356
 msgid "Value must be provided for this test"
 msgstr ""
 
-#: stock/models.py:1311
+#: stock/models.py:1362
 msgid "Attachment must be uploaded for this test"
 msgstr ""
 
-#: stock/models.py:1328
+#: stock/models.py:1379
 msgid "Test"
 msgstr ""
 
-#: stock/models.py:1329
+#: stock/models.py:1380
 msgid "Test name"
 msgstr ""
 
-#: stock/models.py:1334
+#: stock/models.py:1385
 msgid "Result"
 msgstr ""
 
-#: stock/models.py:1335 templates/js/table_filters.html:111
+#: stock/models.py:1386 templates/js/table_filters.html:111
 msgid "Test result"
 msgstr ""
 
-#: stock/models.py:1341
+#: stock/models.py:1392
 msgid "Test output value"
 msgstr ""
 
-#: stock/models.py:1347
+#: stock/models.py:1398
 msgid "Attachment"
 msgstr ""
 
-#: stock/models.py:1348
+#: stock/models.py:1399
 msgid "Test result attachment"
 msgstr ""
 
-#: stock/models.py:1354
+#: stock/models.py:1405
 msgid "Test notes"
 msgstr ""
 
@@ -3312,102 +3357,106 @@ msgid ""
 "This stock item will be automatically deleted when all stock is depleted."
 msgstr ""
 
-#: stock/templates/stock/item_base.html:83 templates/js/barcode.html:283
+#: stock/templates/stock/item_base.html:86 templates/js/barcode.html:283
 #: templates/js/barcode.html:288
 msgid "Unlink Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:85
+#: stock/templates/stock/item_base.html:88
 msgid "Link Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:91
+#: stock/templates/stock/item_base.html:94
 msgid "Stock adjustment actions"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:95
-#: stock/templates/stock/location.html:33 templates/stock_table.html:14
+#: stock/templates/stock/item_base.html:98
+#: stock/templates/stock/location.html:38 templates/stock_table.html:15
 msgid "Count stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:96 templates/stock_table.html:12
+#: stock/templates/stock/item_base.html:99 templates/stock_table.html:13
 msgid "Add stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:97 templates/stock_table.html:13
+#: stock/templates/stock/item_base.html:100 templates/stock_table.html:14
 msgid "Remove stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:99
+#: stock/templates/stock/item_base.html:102
 msgid "Transfer stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:101
+#: stock/templates/stock/item_base.html:104
 msgid "Serialize stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:105
+#: stock/templates/stock/item_base.html:108
 msgid "Assign to customer"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:108
+#: stock/templates/stock/item_base.html:111
 msgid "Return to stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:114
-#: stock/templates/stock/location.html:30
+#: stock/templates/stock/item_base.html:115 templates/js/stock.html:933
+msgid "Uninstall stock item"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:115
+msgid "Uninstall"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:122
+#: stock/templates/stock/location.html:35
 msgid "Stock actions"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:118
+#: stock/templates/stock/item_base.html:126
 msgid "Convert to variant"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:120
+#: stock/templates/stock/item_base.html:128
 msgid "Duplicate stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:121
+#: stock/templates/stock/item_base.html:129
 msgid "Edit stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:123
+#: stock/templates/stock/item_base.html:131
 msgid "Delete stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:127
+#: stock/templates/stock/item_base.html:135
 msgid "Generate test report"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:135
+#: stock/templates/stock/item_base.html:143
 msgid "Stock Item Details"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:168
-msgid "Belongs To"
-msgstr ""
-
-#: stock/templates/stock/item_base.html:190
+#: stock/templates/stock/item_base.html:202
 msgid "No location set"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:197
+#: stock/templates/stock/item_base.html:209
 msgid "Unique Identifier"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:225
+#: stock/templates/stock/item_base.html:237
 msgid "Parent Item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:250
+#: stock/templates/stock/item_base.html:262
 msgid "Last Updated"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:255
+#: stock/templates/stock/item_base.html:267
 msgid "Last Stocktake"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:259
+#: stock/templates/stock/item_base.html:271
 msgid "No stocktake performed"
 msgstr ""
 
@@ -3423,29 +3472,32 @@ msgstr ""
 msgid "Are you sure you want to delete this stock item?"
 msgstr ""
 
+#: stock/templates/stock/item_install.html:7
+msgid "Install another StockItem into this item."
+msgstr ""
+
+#: stock/templates/stock/item_install.html:10
+msgid "Stock items can only be installed if they meet the following criteria"
+msgstr ""
+
+#: stock/templates/stock/item_install.html:13
+msgid "The StockItem links to a Part which is in the BOM for this StockItem"
+msgstr ""
+
+#: stock/templates/stock/item_install.html:14
+msgid "The StockItem is currently in stock"
+msgstr ""
+
 #: stock/templates/stock/item_installed.html:10
 msgid "Installed Stock Items"
 msgstr ""
 
-#: stock/templates/stock/item_installed.html:18
-msgid "Uninstall selected stock items"
+#: stock/templates/stock/item_serialize.html:5
+msgid "Create serialized items from this stock item."
 msgstr ""
 
-#: stock/templates/stock/item_installed.html:18
-msgid "Uninstall"
-msgstr ""
-
-#: stock/templates/stock/item_installed.html:35
-msgid "No stock items installed"
-msgstr ""
-
-#: stock/templates/stock/item_installed.html:48 templates/js/part.html:209
-#: templates/js/stock.html:409
-msgid "Select"
-msgstr ""
-
-#: stock/templates/stock/item_installed.html:131
-msgid "Uninstall item"
+#: stock/templates/stock/item_serialize.html:7
+msgid "Select quantity to serialize, and unique serial numbers."
 msgstr ""
 
 #: stock/templates/stock/item_tests.html:10 stock/templates/stock/tabs.html:13
@@ -3464,54 +3516,54 @@ msgstr ""
 msgid "Test Report"
 msgstr ""
 
-#: stock/templates/stock/location.html:13
+#: stock/templates/stock/location.html:18
 msgid "All stock items"
 msgstr ""
 
-#: stock/templates/stock/location.html:26
+#: stock/templates/stock/location.html:31
 msgid "Check-in Items"
 msgstr ""
 
-#: stock/templates/stock/location.html:37
+#: stock/templates/stock/location.html:42
 msgid "Location actions"
 msgstr ""
 
-#: stock/templates/stock/location.html:39
+#: stock/templates/stock/location.html:44
 msgid "Edit location"
 msgstr ""
 
-#: stock/templates/stock/location.html:40
+#: stock/templates/stock/location.html:45
 msgid "Delete location"
 msgstr ""
 
-#: stock/templates/stock/location.html:48
+#: stock/templates/stock/location.html:53
 msgid "Location Details"
 msgstr ""
 
-#: stock/templates/stock/location.html:53
+#: stock/templates/stock/location.html:58
 msgid "Location Path"
 msgstr ""
 
-#: stock/templates/stock/location.html:58
+#: stock/templates/stock/location.html:63
 msgid "Location Description"
 msgstr ""
 
-#: stock/templates/stock/location.html:63
+#: stock/templates/stock/location.html:68
 msgid "Sublocations"
 msgstr ""
 
-#: stock/templates/stock/location.html:68
-#: stock/templates/stock/location.html:83
+#: stock/templates/stock/location.html:73
+#: stock/templates/stock/location.html:88
 #: templates/InvenTree/search_stock_items.html:6 templates/stats.html:21
 #: templates/stats.html:30
 msgid "Stock Items"
 msgstr ""
 
-#: stock/templates/stock/location.html:73
+#: stock/templates/stock/location.html:78
 msgid "Stock Details"
 msgstr ""
 
-#: stock/templates/stock/location.html:78
+#: stock/templates/stock/location.html:83
 #: templates/InvenTree/search_stock_location.html:6 templates/stats.html:25
 msgid "Stock Locations"
 msgstr ""
@@ -3524,7 +3576,7 @@ msgstr ""
 msgid "The following stock items will be uninstalled"
 msgstr ""
 
-#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1186
+#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1287
 msgid "Convert Stock Item"
 msgstr ""
 
@@ -3636,130 +3688,134 @@ msgstr ""
 msgid "Stock Item QR Code"
 msgstr ""
 
-#: stock/views.py:699
+#: stock/views.py:700
+msgid "Install Stock Item"
+msgstr ""
+
+#: stock/views.py:799
 msgid "Uninstall Stock Items"
 msgstr ""
 
-#: stock/views.py:806
+#: stock/views.py:906
 msgid "Uninstalled stock items"
 msgstr ""
 
-#: stock/views.py:831
+#: stock/views.py:931
 msgid "Adjust Stock"
 msgstr ""
 
-#: stock/views.py:940
+#: stock/views.py:1040
 msgid "Move Stock Items"
 msgstr ""
 
-#: stock/views.py:941
+#: stock/views.py:1041
 msgid "Count Stock Items"
 msgstr ""
 
-#: stock/views.py:942
+#: stock/views.py:1042
 msgid "Remove From Stock"
 msgstr ""
 
-#: stock/views.py:943
+#: stock/views.py:1043
 msgid "Add Stock Items"
 msgstr ""
 
-#: stock/views.py:944
+#: stock/views.py:1044
 msgid "Delete Stock Items"
 msgstr ""
 
-#: stock/views.py:972
+#: stock/views.py:1072
 msgid "Must enter integer value"
 msgstr ""
 
-#: stock/views.py:977
+#: stock/views.py:1077
 msgid "Quantity must be positive"
 msgstr ""
 
-#: stock/views.py:984
+#: stock/views.py:1084
 #, python-brace-format
 msgid "Quantity must not exceed {x}"
 msgstr ""
 
-#: stock/views.py:1063
+#: stock/views.py:1163
 #, python-brace-format
 msgid "Added stock to {n} items"
 msgstr ""
 
-#: stock/views.py:1078
+#: stock/views.py:1178
 #, python-brace-format
 msgid "Removed stock from {n} items"
 msgstr ""
 
-#: stock/views.py:1091
+#: stock/views.py:1191
 #, python-brace-format
 msgid "Counted stock for {n} items"
 msgstr ""
 
-#: stock/views.py:1119
+#: stock/views.py:1219
 msgid "No items were moved"
 msgstr ""
 
-#: stock/views.py:1122
+#: stock/views.py:1222
 #, python-brace-format
 msgid "Moved {n} items to {dest}"
 msgstr ""
 
-#: stock/views.py:1141
+#: stock/views.py:1241
 #, python-brace-format
 msgid "Deleted {n} stock items"
 msgstr ""
 
-#: stock/views.py:1153
+#: stock/views.py:1253
 msgid "Edit Stock Item"
 msgstr ""
 
-#: stock/views.py:1234
+#: stock/views.py:1335
 msgid "Serialize Stock"
 msgstr ""
 
-#: stock/views.py:1426
+#: stock/views.py:1527
 msgid "Duplicate Stock Item"
 msgstr ""
 
-#: stock/views.py:1492
+#: stock/views.py:1593
 msgid "Invalid quantity"
 msgstr ""
 
-#: stock/views.py:1495
+#: stock/views.py:1596
 msgid "Quantity cannot be less than zero"
 msgstr ""
 
-#: stock/views.py:1499
+#: stock/views.py:1600
 msgid "Invalid part selection"
 msgstr ""
 
-#: stock/views.py:1548
+#: stock/views.py:1649
 #, python-brace-format
 msgid "Created {n} new stock items"
 msgstr ""
 
-#: stock/views.py:1567 stock/views.py:1583
+#: stock/views.py:1668 stock/views.py:1684
 msgid "Created new stock item"
 msgstr ""
 
-#: stock/views.py:1602
+#: stock/views.py:1703
 msgid "Delete Stock Location"
 msgstr ""
 
-#: stock/views.py:1615
+#: stock/views.py:1716
 msgid "Delete Stock Item"
 msgstr ""
 
-#: stock/views.py:1626
+#: stock/views.py:1727
 msgid "Delete Stock Tracking Entry"
 msgstr ""
 
-#: stock/views.py:1643
+#: stock/views.py:1744
 msgid "Edit Stock Tracking Entry"
 msgstr ""
 
-#: stock/views.py:1652
+#: stock/views.py:1753
 msgid "Add Stock Tracking Entry"
 msgstr ""
 
@@ -3787,15 +3843,19 @@ msgstr ""
 msgid "Search Results"
 msgstr ""
 
-#: templates/InvenTree/search.html:22
-msgid "No results found"
+#: templates/InvenTree/search.html:24
+msgid "No results found for "
 msgstr ""
 
-#: templates/InvenTree/search.html:181 templates/js/stock.html:521
+#: templates/InvenTree/search.html:42
+msgid "Enter a search query"
+msgstr ""
+
+#: templates/InvenTree/search.html:191 templates/js/stock.html:527
 msgid "Shipped to customer"
 msgstr ""
 
-#: templates/InvenTree/search.html:184 templates/js/stock.html:528
+#: templates/InvenTree/search.html:194 templates/js/stock.html:537
 msgid "No stock location set"
 msgstr ""
 
@@ -3974,31 +4034,35 @@ msgstr ""
 msgid "Open subassembly"
 msgstr ""
 
-#: templates/js/bom.html:184 templates/js/build.html:119
+#: templates/js/bom.html:173
+msgid "Optional"
+msgstr ""
+
+#: templates/js/bom.html:188 templates/js/build.html:119
 msgid "Available"
 msgstr ""
 
-#: templates/js/bom.html:209
+#: templates/js/bom.html:213
 msgid "No pricing available"
 msgstr ""
 
-#: templates/js/bom.html:228
+#: templates/js/bom.html:232
 msgid "Actions"
 msgstr ""
 
-#: templates/js/bom.html:236
+#: templates/js/bom.html:240
 msgid "Validate BOM Item"
 msgstr ""
 
-#: templates/js/bom.html:238
+#: templates/js/bom.html:242
 msgid "This line has been validated"
 msgstr ""
 
-#: templates/js/bom.html:240
+#: templates/js/bom.html:244
 msgid "Edit BOM Item"
 msgstr ""
 
-#: templates/js/bom.html:242
+#: templates/js/bom.html:246
 msgid "Delete BOM Item"
 msgstr ""
 
@@ -4026,11 +4090,11 @@ msgstr ""
 msgid "No supplier parts found"
 msgstr ""
 
-#: templates/js/company.html:145 templates/js/part.html:248
+#: templates/js/company.html:145 templates/js/part.html:314
 msgid "Template part"
 msgstr ""
 
-#: templates/js/company.html:149 templates/js/part.html:252
+#: templates/js/company.html:149 templates/js/part.html:318
 msgid "Assembled part"
 msgstr ""
 
@@ -4042,7 +4106,7 @@ msgstr ""
 msgid "No purchase orders found"
 msgstr ""
 
-#: templates/js/order.html:172 templates/js/stock.html:633
+#: templates/js/order.html:172 templates/js/stock.html:642
 msgid "Date"
 msgstr ""
 
@@ -4058,51 +4122,56 @@ msgstr ""
 msgid "No variants found"
 msgstr ""
 
-#: templates/js/part.html:256
-msgid "Starred part"
-msgstr ""
-
-#: templates/js/part.html:260
-msgid "Salable part"
-msgstr ""
-
-#: templates/js/part.html:299
-msgid "No category"
-msgstr ""
-
-#: templates/js/part.html:317 templates/js/table_filters.html:191
-msgid "Low stock"
-msgstr ""
-
-#: templates/js/part.html:326
-msgid "Building"
-msgstr ""
-
-#: templates/js/part.html:345
+#: templates/js/part.html:223 templates/js/part.html:411
 msgid "No parts found"
 msgstr ""
 
-#: templates/js/part.html:405
+#: templates/js/part.html:275 templates/js/stock.html:409
+#: templates/js/stock.html:965
+msgid "Select"
+msgstr ""
+
+#: templates/js/part.html:322
+msgid "Starred part"
+msgstr ""
+
+#: templates/js/part.html:326
+msgid "Salable part"
+msgstr ""
+
+#: templates/js/part.html:365
+msgid "No category"
+msgstr ""
+
+#: templates/js/part.html:383 templates/js/table_filters.html:196
+msgid "Low stock"
+msgstr ""
+
+#: templates/js/part.html:392
+msgid "Building"
+msgstr ""
+
+#: templates/js/part.html:471
 msgid "YES"
 msgstr ""
 
-#: templates/js/part.html:407
+#: templates/js/part.html:473
 msgid "NO"
 msgstr ""
 
-#: templates/js/part.html:441
+#: templates/js/part.html:507
 msgid "No test templates matching query"
 msgstr ""
 
-#: templates/js/part.html:492 templates/js/stock.html:63
+#: templates/js/part.html:558 templates/js/stock.html:63
 msgid "Edit test result"
 msgstr ""
 
-#: templates/js/part.html:493 templates/js/stock.html:64
+#: templates/js/part.html:559 templates/js/stock.html:64
 msgid "Delete test result"
 msgstr ""
 
-#: templates/js/part.html:499
+#: templates/js/part.html:565
 msgid "This test is defined for a parent part"
 msgstr ""
 
@@ -4146,42 +4215,62 @@ msgstr ""
 msgid "Stock item has been assigned to customer"
 msgstr ""
 
-#: templates/js/stock.html:474
+#: templates/js/stock.html:475
 msgid "Stock item was assigned to a build order"
 msgstr ""
 
-#: templates/js/stock.html:476
+#: templates/js/stock.html:477
 msgid "Stock item was assigned to a sales order"
 msgstr ""
 
-#: templates/js/stock.html:483
+#: templates/js/stock.html:482
+msgid "Stock item has been installed in another item"
+msgstr ""
+
+#: templates/js/stock.html:489
 msgid "Stock item has been rejected"
 msgstr ""
 
-#: templates/js/stock.html:487
+#: templates/js/stock.html:493
 msgid "Stock item is lost"
 msgstr ""
 
-#: templates/js/stock.html:491 templates/js/table_filters.html:60
+#: templates/js/stock.html:497 templates/js/table_filters.html:60
 msgid "Depleted"
 msgstr ""
 
-#: templates/js/stock.html:516
+#: templates/js/stock.html:522
 msgid "Installed in Stock Item "
 msgstr ""
 
-#: templates/js/stock.html:699
+#: templates/js/stock.html:530
+msgid "Assigned to sales order"
+msgstr ""
+
+#: templates/js/stock.html:708
 msgid "No user information"
 msgstr ""
 
-#: templates/js/stock.html:783
+#: templates/js/stock.html:792
 msgid "Create New Part"
 msgstr ""
 
-#: templates/js/stock.html:795
+#: templates/js/stock.html:804
 msgid "Create New Location"
 msgstr ""
 
+#: templates/js/stock.html:903
+msgid "Serial"
+msgstr ""
+
+#: templates/js/stock.html:996 templates/js/table_filters.html:70
+msgid "Installed"
+msgstr ""
+
+#: templates/js/stock.html:1021
+msgid "Install item"
+msgstr ""
+
 #: templates/js/table_filters.html:19 templates/js/table_filters.html:80
 msgid "Is Serialized"
 msgstr ""
@@ -4243,10 +4332,6 @@ msgstr ""
 msgid "Show items which are in stock"
 msgstr ""
 
-#: templates/js/table_filters.html:70
-msgid "Installed"
-msgstr ""
-
 #: templates/js/table_filters.html:71
 msgid "Show stock items which are installed in another item"
 msgstr ""
@@ -4283,19 +4368,27 @@ msgstr ""
 msgid "Include parts in subcategories"
 msgstr ""
 
+#: templates/js/table_filters.html:178
+msgid "Has IPN"
+msgstr ""
+
 #: templates/js/table_filters.html:179
+msgid "Part has internal part number"
+msgstr ""
+
+#: templates/js/table_filters.html:184
 msgid "Show active parts"
 msgstr ""
 
-#: templates/js/table_filters.html:187
+#: templates/js/table_filters.html:192
 msgid "Stock available"
 msgstr ""
 
-#: templates/js/table_filters.html:203
+#: templates/js/table_filters.html:208
 msgid "Starred"
 msgstr ""
 
-#: templates/js/table_filters.html:215
+#: templates/js/table_filters.html:220
 msgid "Purchasable"
 msgstr ""
 
@@ -4339,42 +4432,42 @@ msgstr ""
 msgid "Search"
 msgstr ""
 
-#: templates/stock_table.html:5
+#: templates/stock_table.html:6
 msgid "Export Stock Information"
 msgstr ""
 
-#: templates/stock_table.html:12
+#: templates/stock_table.html:13
 msgid "Add to selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:13
+#: templates/stock_table.html:14
 msgid "Remove from selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:14
+#: templates/stock_table.html:15
 msgid "Stocktake selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:16
 msgid "Move selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:16
 msgid "Move stock"
 msgstr ""
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:17
 msgid "Order selected items"
 msgstr ""
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:17
 msgid "Order stock"
 msgstr ""
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:18
 msgid "Delete selected items"
 msgstr ""
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:18
 msgid "Delete Stock"
 msgstr ""
diff --git a/InvenTree/locale/es/LC_MESSAGES/django.po b/InvenTree/locale/es/LC_MESSAGES/django.po
index d4a3d7ceb6..0b5425db6e 100644
--- a/InvenTree/locale/es/LC_MESSAGES/django.po
+++ b/InvenTree/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-09-28 12:03+0000\n"
+"POT-Creation-Date: 2020-10-04 14:02+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -86,7 +86,7 @@ msgstr ""
 msgid "File comment"
 msgstr ""
 
-#: InvenTree/models.py:68 templates/js/stock.html:690
+#: InvenTree/models.py:68 templates/js/stock.html:699
 msgid "User"
 msgstr ""
 
@@ -99,19 +99,19 @@ msgstr ""
 msgid "Description (optional)"
 msgstr ""
 
-#: InvenTree/settings.py:341
+#: InvenTree/settings.py:342
 msgid "English"
 msgstr ""
 
-#: InvenTree/settings.py:342
+#: InvenTree/settings.py:343
 msgid "German"
 msgstr ""
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:344
 msgid "French"
 msgstr ""
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:345
 msgid "Polish"
 msgstr ""
 
@@ -143,7 +143,8 @@ msgstr ""
 msgid "Returned"
 msgstr ""
 
-#: InvenTree/status_codes.py:136 order/templates/order/sales_order_base.html:98
+#: InvenTree/status_codes.py:136
+#: order/templates/order/sales_order_base.html:103
 msgid "Shipped"
 msgstr ""
 
@@ -198,7 +199,7 @@ msgstr ""
 msgid "Overage must be an integer value or a percentage"
 msgstr ""
 
-#: InvenTree/views.py:639
+#: InvenTree/views.py:661
 msgid "Database Statistics"
 msgstr ""
 
@@ -250,7 +251,7 @@ msgstr ""
 msgid "Serial numbers"
 msgstr ""
 
-#: build/forms.py:64 stock/forms.py:107
+#: build/forms.py:64 stock/forms.py:111
 msgid "Enter unique serial numbers (or leave blank)"
 msgstr ""
 
@@ -262,7 +263,7 @@ msgstr ""
 msgid "Build quantity must be integer value for trackable parts"
 msgstr ""
 
-#: build/models.py:73 build/templates/build/build_base.html:65
+#: build/models.py:73 build/templates/build/build_base.html:70
 msgid "Build Title"
 msgstr ""
 
@@ -270,7 +271,7 @@ msgstr ""
 msgid "Brief description of the build"
 msgstr ""
 
-#: build/models.py:84 build/templates/build/build_base.html:86
+#: build/models.py:84 build/templates/build/build_base.html:91
 msgid "Parent Build"
 msgstr ""
 
@@ -280,18 +281,17 @@ msgstr ""
 
 #: build/models.py:90 build/templates/build/allocate.html:329
 #: build/templates/build/auto_allocate.html:19
-#: build/templates/build/build_base.html:70
+#: build/templates/build/build_base.html:75
 #: build/templates/build/detail.html:22 order/models.py:501
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:147
-#: order/templates/order/receive_parts.html:19 part/models.py:241
+#: order/templates/order/receive_parts.html:19 part/models.py:293
 #: part/templates/part/part_app_base.html:7
-#: part/templates/part/set_category.html:13
-#: stock/templates/stock/item_installed.html:60
-#: templates/InvenTree/search.html:123 templates/js/barcode.html:336
-#: templates/js/bom.html:124 templates/js/build.html:47
-#: templates/js/company.html:137 templates/js/part.html:223
-#: templates/js/stock.html:421
+#: part/templates/part/set_category.html:13 templates/InvenTree/search.html:133
+#: templates/js/barcode.html:336 templates/js/bom.html:124
+#: templates/js/build.html:47 templates/js/company.html:137
+#: templates/js/part.html:184 templates/js/part.html:289
+#: templates/js/stock.html:421 templates/js/stock.html:977
 msgid "Part"
 msgstr ""
 
@@ -325,7 +325,7 @@ msgstr ""
 msgid "Number of parts to build"
 msgstr ""
 
-#: build/models.py:128 part/templates/part/part_base.html:142
+#: build/models.py:128 part/templates/part/part_base.html:145
 msgid "Build Status"
 msgstr ""
 
@@ -333,7 +333,7 @@ msgstr ""
 msgid "Build status code"
 msgstr ""
 
-#: build/models.py:136 stock/models.py:371
+#: build/models.py:136 stock/models.py:387
 msgid "Batch Code"
 msgstr ""
 
@@ -344,12 +344,12 @@ msgstr ""
 #: build/models.py:155 build/templates/build/detail.html:55
 #: company/templates/company/supplier_part_base.html:60
 #: company/templates/company/supplier_part_detail.html:24
-#: part/templates/part/detail.html:80 part/templates/part/part_base.html:89
-#: stock/models.py:365 stock/templates/stock/item_base.html:232
+#: part/templates/part/detail.html:80 part/templates/part/part_base.html:92
+#: stock/models.py:381 stock/templates/stock/item_base.html:244
 msgid "External Link"
 msgstr ""
 
-#: build/models.py:156 stock/models.py:367
+#: build/models.py:156 stock/models.py:383
 msgid "Link to external URL"
 msgstr ""
 
@@ -357,10 +357,10 @@ msgstr ""
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:15
 #: order/templates/order/purchase_order_detail.html:202
 #: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:67
-#: stock/forms.py:281 stock/forms.py:309 stock/models.py:433
-#: stock/models.py:1353 stock/templates/stock/tabs.html:26
-#: templates/js/barcode.html:391 templates/js/bom.html:219
-#: templates/js/stock.html:116 templates/js/stock.html:534
+#: stock/forms.py:306 stock/forms.py:338 stock/forms.py:366 stock/models.py:453
+#: stock/models.py:1404 stock/templates/stock/tabs.html:26
+#: templates/js/barcode.html:391 templates/js/bom.html:223
+#: templates/js/stock.html:116 templates/js/stock.html:543
 msgid "Notes"
 msgstr ""
 
@@ -404,7 +404,7 @@ msgstr ""
 
 #: build/templates/build/allocate.html:17
 #: company/templates/company/detail_part.html:18 order/views.py:779
-#: part/templates/part/category.html:107
+#: part/templates/part/category.html:112
 msgid "Order Parts"
 msgstr ""
 
@@ -420,24 +420,24 @@ msgstr ""
 msgid "Unallocate"
 msgstr ""
 
-#: build/templates/build/allocate.html:87 templates/stock_table.html:8
+#: build/templates/build/allocate.html:87 templates/stock_table.html:9
 msgid "New Stock Item"
 msgstr ""
 
-#: build/templates/build/allocate.html:88 stock/views.py:1327
+#: build/templates/build/allocate.html:88 stock/views.py:1428
 msgid "Create new Stock Item"
 msgstr ""
 
 #: build/templates/build/allocate.html:170
 #: order/templates/order/sales_order_detail.html:68
-#: order/templates/order/sales_order_detail.html:150 stock/models.py:359
-#: stock/templates/stock/item_base.html:148
+#: order/templates/order/sales_order_detail.html:150 stock/models.py:375
+#: stock/templates/stock/item_base.html:156
 msgid "Serial Number"
 msgstr ""
 
 #: build/templates/build/allocate.html:172
 #: build/templates/build/auto_allocate.html:20
-#: build/templates/build/build_base.html:75
+#: build/templates/build/build_base.html:80
 #: build/templates/build/detail.html:27
 #: company/templates/company/supplier_part_pricing.html:71
 #: order/templates/order/order_wizard/select_parts.html:32
@@ -446,22 +446,22 @@ msgstr ""
 #: order/templates/order/sales_order_detail.html:152
 #: part/templates/part/allocation.html:16
 #: part/templates/part/allocation.html:49
-#: part/templates/part/sale_prices.html:80
+#: part/templates/part/sale_prices.html:80 stock/forms.py:297
 #: stock/templates/stock/item_base.html:26
 #: stock/templates/stock/item_base.html:32
-#: stock/templates/stock/item_base.html:154
+#: stock/templates/stock/item_base.html:162
 #: stock/templates/stock/stock_adjust.html:18 templates/js/barcode.html:338
 #: templates/js/bom.html:162 templates/js/build.html:58
-#: templates/js/stock.html:681
+#: templates/js/stock.html:690 templates/js/stock.html:905
 msgid "Quantity"
 msgstr ""
 
 #: build/templates/build/allocate.html:186
-#: build/templates/build/auto_allocate.html:21 stock/forms.py:279
-#: stock/templates/stock/item_base.html:186
+#: build/templates/build/auto_allocate.html:21 stock/forms.py:336
+#: stock/templates/stock/item_base.html:198
 #: stock/templates/stock/stock_adjust.html:17
-#: templates/InvenTree/search.html:173 templates/js/barcode.html:337
-#: templates/js/stock.html:512
+#: templates/InvenTree/search.html:183 templates/js/barcode.html:337
+#: templates/js/stock.html:518
 msgid "Location"
 msgstr ""
 
@@ -475,7 +475,7 @@ msgstr ""
 msgid "Delete stock allocation"
 msgstr ""
 
-#: build/templates/build/allocate.html:238 templates/js/bom.html:330
+#: build/templates/build/allocate.html:238 templates/js/bom.html:334
 msgid "No BOM items found"
 msgstr ""
 
@@ -484,12 +484,12 @@ msgstr ""
 #: company/templates/company/supplier_part_detail.html:27
 #: order/templates/order/purchase_order_detail.html:159
 #: part/templates/part/detail.html:51 part/templates/part/set_category.html:14
-#: stock/templates/stock/item_installed.html:83
-#: templates/InvenTree/search.html:137 templates/js/bom.html:147
+#: templates/InvenTree/search.html:147 templates/js/bom.html:147
 #: templates/js/company.html:56 templates/js/order.html:159
 #: templates/js/order.html:234 templates/js/part.html:120
-#: templates/js/part.html:279 templates/js/part.html:460
-#: templates/js/stock.html:444 templates/js/stock.html:662
+#: templates/js/part.html:203 templates/js/part.html:345
+#: templates/js/part.html:526 templates/js/stock.html:444
+#: templates/js/stock.html:671
 msgid "Description"
 msgstr ""
 
@@ -499,8 +499,8 @@ msgstr ""
 msgid "Reference"
 msgstr ""
 
-#: build/templates/build/allocate.html:347 part/models.py:1348
-#: templates/js/part.html:464 templates/js/table_filters.html:121
+#: build/templates/build/allocate.html:347 part/models.py:1401
+#: templates/js/part.html:530 templates/js/table_filters.html:121
 msgid "Required"
 msgstr ""
 
@@ -547,7 +547,7 @@ msgstr ""
 #: build/templates/build/build_base.html:8
 #: build/templates/build/build_base.html:34
 #: build/templates/build/complete.html:6
-#: stock/templates/stock/item_base.html:211 templates/js/build.html:39
+#: stock/templates/stock/item_base.html:223 templates/js/build.html:39
 #: templates/navbar.html:20
 msgid "Build"
 msgstr ""
@@ -560,40 +560,49 @@ msgstr ""
 msgid "This build is a child of Build"
 msgstr ""
 
-#: build/templates/build/build_base.html:61 build/templates/build/detail.html:9
+#: build/templates/build/build_base.html:39
+#: company/templates/company/company_base.html:27
+#: order/templates/order/order_base.html:28
+#: order/templates/order/sales_order_base.html:38
+#: part/templates/part/category.html:13 part/templates/part/part_base.html:32
+#: stock/templates/stock/item_base.html:69
+#: stock/templates/stock/location.html:12
+msgid "Admin view"
+msgstr ""
+
+#: build/templates/build/build_base.html:66 build/templates/build/detail.html:9
 msgid "Build Details"
 msgstr ""
 
-#: build/templates/build/build_base.html:80
+#: build/templates/build/build_base.html:85
 #: build/templates/build/detail.html:42
 #: order/templates/order/receive_parts.html:24
-#: stock/templates/stock/item_base.html:264
-#: stock/templates/stock/item_installed.html:111
-#: templates/InvenTree/search.html:165 templates/js/barcode.html:42
-#: templates/js/build.html:63 templates/js/order.html:164
-#: templates/js/order.html:239 templates/js/stock.html:499
+#: stock/templates/stock/item_base.html:276 templates/InvenTree/search.html:175
+#: templates/js/barcode.html:42 templates/js/build.html:63
+#: templates/js/order.html:164 templates/js/order.html:239
+#: templates/js/stock.html:505 templates/js/stock.html:913
 msgid "Status"
 msgstr ""
 
-#: build/templates/build/build_base.html:93 order/models.py:499
+#: build/templates/build/build_base.html:98 order/models.py:499
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
 #: order/templates/order/sales_order_ship.html:25
 #: part/templates/part/allocation.html:27
-#: stock/templates/stock/item_base.html:174 templates/js/order.html:213
+#: stock/templates/stock/item_base.html:186 templates/js/order.html:213
 msgid "Sales Order"
 msgstr ""
 
-#: build/templates/build/build_base.html:99
+#: build/templates/build/build_base.html:104
 msgid "BOM Price"
 msgstr ""
 
-#: build/templates/build/build_base.html:104
+#: build/templates/build/build_base.html:109
 msgid "BOM pricing is incomplete"
 msgstr ""
 
-#: build/templates/build/build_base.html:107
+#: build/templates/build/build_base.html:112
 msgid "No pricing information"
 msgstr ""
 
@@ -648,15 +657,15 @@ msgid "Stock can be taken from any available location."
 msgstr ""
 
 #: build/templates/build/detail.html:48
-#: stock/templates/stock/item_base.html:204
-#: stock/templates/stock/item_installed.html:119 templates/js/stock.html:507
-#: templates/js/table_filters.html:34 templates/js/table_filters.html:100
+#: stock/templates/stock/item_base.html:216 templates/js/stock.html:513
+#: templates/js/stock.html:920 templates/js/table_filters.html:34
+#: templates/js/table_filters.html:100
 msgid "Batch"
 msgstr ""
 
 #: build/templates/build/detail.html:61
-#: order/templates/order/order_base.html:93
-#: order/templates/order/sales_order_base.html:92 templates/js/build.html:71
+#: order/templates/order/order_base.html:98
+#: order/templates/order/sales_order_base.html:97 templates/js/build.html:71
 msgid "Created"
 msgstr ""
 
@@ -769,7 +778,7 @@ msgstr ""
 msgid "Invalid location selected"
 msgstr ""
 
-#: build/views.py:296 stock/views.py:1520
+#: build/views.py:296 stock/views.py:1621
 #, python-brace-format
 msgid "The following serial numbers already exist: ({sn})"
 msgstr ""
@@ -878,7 +887,7 @@ msgstr ""
 msgid "Description of the company"
 msgstr ""
 
-#: company/models.py:91 company/templates/company/company_base.html:48
+#: company/models.py:91 company/templates/company/company_base.html:53
 #: templates/js/company.html:61
 msgid "Website"
 msgstr ""
@@ -887,7 +896,7 @@ msgstr ""
 msgid "Company website URL"
 msgstr ""
 
-#: company/models.py:94 company/templates/company/company_base.html:55
+#: company/models.py:94 company/templates/company/company_base.html:60
 msgid "Address"
 msgstr ""
 
@@ -903,7 +912,7 @@ msgstr ""
 msgid "Contact phone number"
 msgstr ""
 
-#: company/models.py:101 company/templates/company/company_base.html:69
+#: company/models.py:101 company/templates/company/company_base.html:74
 msgid "Email"
 msgstr ""
 
@@ -911,7 +920,7 @@ msgstr ""
 msgid "Contact email address"
 msgstr ""
 
-#: company/models.py:104 company/templates/company/company_base.html:76
+#: company/models.py:104 company/templates/company/company_base.html:81
 msgid "Contact"
 msgstr ""
 
@@ -935,8 +944,8 @@ msgstr ""
 msgid "Does this company manufacture parts?"
 msgstr ""
 
-#: company/models.py:279 stock/models.py:319
-#: stock/templates/stock/item_base.html:140
+#: company/models.py:279 stock/models.py:335
+#: stock/templates/stock/item_base.html:148
 msgid "Base Part"
 msgstr ""
 
@@ -986,12 +995,12 @@ msgstr ""
 msgid "Company"
 msgstr ""
 
-#: company/templates/company/company_base.html:42
+#: company/templates/company/company_base.html:47
 #: company/templates/company/detail.html:8
 msgid "Company Details"
 msgstr ""
 
-#: company/templates/company/company_base.html:62
+#: company/templates/company/company_base.html:67
 msgid "Phone"
 msgstr ""
 
@@ -1005,16 +1014,16 @@ msgstr ""
 #: company/templates/company/detail.html:21
 #: company/templates/company/supplier_part_base.html:66
 #: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:74
+#: order/templates/order/order_base.html:79
 #: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
-#: stock/templates/stock/item_base.html:239 templates/js/company.html:48
+#: stock/templates/stock/item_base.html:251 templates/js/company.html:48
 #: templates/js/company.html:162 templates/js/order.html:146
 msgid "Supplier"
 msgstr ""
 
 #: company/templates/company/detail.html:26
-#: order/templates/order/sales_order_base.html:73 stock/models.py:354
-#: stock/models.py:355 stock/templates/stock/item_base.html:161
+#: order/templates/order/sales_order_base.html:78 stock/models.py:370
+#: stock/models.py:371 stock/templates/stock/item_base.html:169
 #: templates/js/company.html:40 templates/js/order.html:221
 msgid "Customer"
 msgstr ""
@@ -1030,18 +1039,18 @@ msgstr ""
 
 #: company/templates/company/detail_part.html:13
 #: order/templates/order/purchase_order_detail.html:67
-#: part/templates/part/supplier.html:13 templates/js/stock.html:788
+#: part/templates/part/supplier.html:13 templates/js/stock.html:797
 msgid "New Supplier Part"
 msgstr ""
 
 #: company/templates/company/detail_part.html:15
-#: part/templates/part/category.html:104 part/templates/part/supplier.html:15
-#: stock/templates/stock/item_installed.html:16 templates/stock_table.html:10
+#: part/templates/part/category.html:109 part/templates/part/supplier.html:15
+#: templates/stock_table.html:11
 msgid "Options"
 msgstr ""
 
 #: company/templates/company/detail_part.html:18
-#: part/templates/part/category.html:107
+#: part/templates/part/category.html:112
 msgid "Order parts"
 msgstr ""
 
@@ -1054,7 +1063,7 @@ msgid "Delete Parts"
 msgstr ""
 
 #: company/templates/company/detail_part.html:43
-#: part/templates/part/category.html:102 templates/js/stock.html:782
+#: part/templates/part/category.html:107 templates/js/stock.html:791
 msgid "New Part"
 msgstr ""
 
@@ -1086,8 +1095,8 @@ msgstr ""
 
 #: company/templates/company/detail_stock.html:35
 #: company/templates/company/supplier_part_stock.html:33
-#: part/templates/part/category.html:101 part/templates/part/category.html:108
-#: part/templates/part/stock.html:51 templates/stock_table.html:5
+#: part/templates/part/category.html:106 part/templates/part/category.html:113
+#: part/templates/part/stock.html:51 templates/stock_table.html:6
 msgid "Export"
 msgstr ""
 
@@ -1143,8 +1152,8 @@ msgid "New Sales Order"
 msgstr ""
 
 #: company/templates/company/supplier_part_base.html:6
-#: company/templates/company/supplier_part_base.html:19 stock/models.py:328
-#: stock/templates/stock/item_base.html:244 templates/js/company.html:178
+#: company/templates/company/supplier_part_base.html:19 stock/models.py:344
+#: stock/templates/stock/item_base.html:256 templates/js/company.html:178
 msgid "Supplier Part"
 msgstr ""
 
@@ -1196,7 +1205,7 @@ msgid "Pricing Information"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2108
+#: part/templates/part/sale_prices.html:13 part/views.py:2149
 msgid "Add Price Break"
 msgstr ""
 
@@ -1206,7 +1215,7 @@ msgid "No price break information found"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:76
-#: part/templates/part/sale_prices.html:85 templates/js/bom.html:203
+#: part/templates/part/sale_prices.html:85 templates/js/bom.html:207
 msgid "Price"
 msgstr ""
 
@@ -1230,9 +1239,8 @@ msgstr ""
 
 #: company/templates/company/supplier_part_tabs.html:8
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
-#: stock/templates/stock/item_installed.html:91
-#: stock/templates/stock/location.html:12 templates/InvenTree/search.html:145
-#: templates/js/part.html:124 templates/js/part.html:306
+#: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
+#: templates/js/part.html:124 templates/js/part.html:372
 #: templates/js/stock.html:452 templates/navbar.html:19
 msgid "Stock"
 msgstr ""
@@ -1242,9 +1250,10 @@ msgid "Orders"
 msgstr ""
 
 #: company/templates/company/tabs.html:9
-#: order/templates/order/receive_parts.html:14 part/models.py:242
-#: part/templates/part/cat_link.html:7 part/templates/part/category.html:83
-#: templates/navbar.html:18 templates/stats.html:8 templates/stats.html:17
+#: order/templates/order/receive_parts.html:14 part/models.py:294
+#: part/templates/part/cat_link.html:7 part/templates/part/category.html:88
+#: part/templates/part/category_tabs.html:6 templates/navbar.html:18
+#: templates/stats.html:8 templates/stats.html:17
 msgid "Parts"
 msgstr ""
 
@@ -1313,7 +1322,7 @@ msgstr ""
 msgid "Edit Supplier Part"
 msgstr ""
 
-#: company/views.py:269 templates/js/stock.html:789
+#: company/views.py:269 templates/js/stock.html:798
 msgid "Create new Supplier Part"
 msgstr ""
 
@@ -1321,15 +1330,15 @@ msgstr ""
 msgid "Delete Supplier Part"
 msgstr ""
 
-#: company/views.py:404 part/views.py:2112
+#: company/views.py:404 part/views.py:2153
 msgid "Added new price break"
 msgstr ""
 
-#: company/views.py:441 part/views.py:2157
+#: company/views.py:441 part/views.py:2198
 msgid "Edit Price Break"
 msgstr ""
 
-#: company/views.py:456 part/views.py:2171
+#: company/views.py:456 part/views.py:2212
 msgid "Delete Price Break"
 msgstr ""
 
@@ -1366,11 +1375,11 @@ msgid "Mark order as complete"
 msgstr ""
 
 #: order/forms.py:46 order/forms.py:57
-#: order/templates/order/sales_order_base.html:49
+#: order/templates/order/sales_order_base.html:54
 msgid "Cancel order"
 msgstr ""
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:46
+#: order/forms.py:68 order/templates/order/sales_order_base.html:51
 msgid "Ship order"
 msgstr ""
 
@@ -1423,7 +1432,7 @@ msgid "Date order was completed"
 msgstr ""
 
 #: order/models.py:185 order/models.py:259 part/views.py:1304
-#: stock/models.py:239 stock/models.py:754
+#: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr ""
 
@@ -1461,7 +1470,7 @@ msgstr ""
 
 #: order/models.py:466 order/templates/order/order_base.html:9
 #: order/templates/order/order_base.html:23
-#: stock/templates/stock/item_base.html:218 templates/js/order.html:138
+#: stock/templates/stock/item_base.html:230 templates/js/order.html:138
 msgid "Purchase Order"
 msgstr ""
 
@@ -1503,32 +1512,32 @@ msgstr ""
 msgid "Are you sure you want to delete this attachment?"
 msgstr ""
 
-#: order/templates/order/order_base.html:59
-msgid "Purchase Order Details"
-msgstr ""
-
 #: order/templates/order/order_base.html:64
-#: order/templates/order/sales_order_base.html:63
-msgid "Order Reference"
+msgid "Purchase Order Details"
 msgstr ""
 
 #: order/templates/order/order_base.html:69
 #: order/templates/order/sales_order_base.html:68
+msgid "Order Reference"
+msgstr ""
+
+#: order/templates/order/order_base.html:74
+#: order/templates/order/sales_order_base.html:73
 msgid "Order Status"
 msgstr ""
 
-#: order/templates/order/order_base.html:80 templates/js/order.html:153
+#: order/templates/order/order_base.html:85 templates/js/order.html:153
 msgid "Supplier Reference"
 msgstr ""
 
-#: order/templates/order/order_base.html:99
+#: order/templates/order/order_base.html:104
 msgid "Issued"
 msgstr ""
 
-#: order/templates/order/order_base.html:106
+#: order/templates/order/order_base.html:111
 #: order/templates/order/purchase_order_detail.html:182
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:105
+#: order/templates/order/sales_order_base.html:110
 msgid "Received"
 msgstr ""
 
@@ -1607,14 +1616,14 @@ msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:38
 #: order/templates/order/purchase_order_detail.html:118
-#: part/templates/part/category.html:153 part/templates/part/category.html:194
-#: templates/js/stock.html:794
+#: part/templates/part/category.html:161 part/templates/part/category.html:202
+#: templates/js/stock.html:803
 msgid "New Location"
 msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:39
 #: order/templates/order/purchase_order_detail.html:119
-#: stock/templates/stock/location.html:16
+#: stock/templates/stock/location.html:21
 msgid "Create new stock location"
 msgstr ""
 
@@ -1649,7 +1658,7 @@ msgid "Select parts to receive against this order"
 msgstr ""
 
 #: order/templates/order/receive_parts.html:21
-#: part/templates/part/part_base.html:132 templates/js/part.html:322
+#: part/templates/part/part_base.html:135 templates/js/part.html:388
 msgid "On Order"
 msgstr ""
 
@@ -1665,15 +1674,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:42
+#: order/templates/order/sales_order_base.html:47
 msgid "Packing List"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:58
+#: order/templates/order/sales_order_base.html:63
 msgid "Sales Order Details"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:79 templates/js/order.html:228
+#: order/templates/order/sales_order_base.html:84 templates/js/order.html:228
 msgid "Customer Reference"
 msgstr ""
 
@@ -1881,12 +1890,12 @@ msgstr ""
 msgid "Remove allocation"
 msgstr ""
 
-#: part/bom.py:138 part/templates/part/category.html:50
+#: part/bom.py:138 part/templates/part/category.html:55
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr ""
 
-#: part/bom.py:139 part/templates/part/part_base.html:105
+#: part/bom.py:139 part/templates/part/part_base.html:108
 msgid "Available Stock"
 msgstr ""
 
@@ -1903,11 +1912,11 @@ msgstr ""
 msgid "Error reading BOM file (incorrect row size)"
 msgstr ""
 
-#: part/forms.py:57 stock/forms.py:250
+#: part/forms.py:57 stock/forms.py:254
 msgid "File Format"
 msgstr ""
 
-#: part/forms.py:57 stock/forms.py:250
+#: part/forms.py:57 stock/forms.py:254
 msgid "Select output file format"
 msgstr ""
 
@@ -1983,11 +1992,11 @@ msgstr ""
 msgid "Confirm part creation"
 msgstr ""
 
-#: part/forms.py:247
+#: part/forms.py:248
 msgid "Input quantity for price calculation"
 msgstr ""
 
-#: part/forms.py:250
+#: part/forms.py:251
 msgid "Select currency for price calculation"
 msgstr ""
 
@@ -2003,222 +2012,226 @@ msgstr ""
 msgid "Part Category"
 msgstr ""
 
-#: part/models.py:76 part/templates/part/category.html:13
-#: part/templates/part/category.html:78 templates/stats.html:12
+#: part/models.py:76 part/templates/part/category.html:18
+#: part/templates/part/category.html:83 templates/stats.html:12
 msgid "Part Categories"
 msgstr ""
 
-#: part/models.py:293 part/models.py:303
+#: part/models.py:345 part/models.py:355
 #, python-brace-format
 msgid "Part '{p1}' is  used in BOM for '{p2}' (recursive)"
 msgstr ""
 
-#: part/models.py:383
+#: part/models.py:435
 msgid "Next available serial numbers are"
 msgstr ""
 
-#: part/models.py:387
+#: part/models.py:439
 msgid "Next available serial number is"
 msgstr ""
 
-#: part/models.py:392
+#: part/models.py:444
 msgid "Most recent serial number is"
 msgstr ""
 
-#: part/models.py:470
+#: part/models.py:522
 msgid "Part must be unique for name, IPN and revision"
 msgstr ""
 
-#: part/models.py:485 part/templates/part/detail.html:19
+#: part/models.py:537 part/templates/part/detail.html:19
 msgid "Part name"
 msgstr ""
 
-#: part/models.py:489
+#: part/models.py:541
 msgid "Is this part a template part?"
 msgstr ""
 
-#: part/models.py:498
+#: part/models.py:550
 msgid "Is this part a variant of another part?"
 msgstr ""
 
-#: part/models.py:500
+#: part/models.py:552
 msgid "Part description"
 msgstr ""
 
-#: part/models.py:502
+#: part/models.py:554
 msgid "Part keywords to improve visibility in search results"
 msgstr ""
 
-#: part/models.py:507
+#: part/models.py:559
 msgid "Part category"
 msgstr ""
 
-#: part/models.py:509
+#: part/models.py:561
 msgid "Internal Part Number"
 msgstr ""
 
-#: part/models.py:511
+#: part/models.py:563
 msgid "Part revision or version number"
 msgstr ""
 
-#: part/models.py:513
+#: part/models.py:565
 msgid "Link to extenal URL"
 msgstr ""
 
-#: part/models.py:525
+#: part/models.py:577
 msgid "Where is this item normally stored?"
 msgstr ""
 
-#: part/models.py:569
+#: part/models.py:621
 msgid "Default supplier part"
 msgstr ""
 
-#: part/models.py:572
+#: part/models.py:624
 msgid "Minimum allowed stock level"
 msgstr ""
 
-#: part/models.py:574
+#: part/models.py:626
 msgid "Stock keeping units for this part"
 msgstr ""
 
-#: part/models.py:576
+#: part/models.py:628
 msgid "Can this part be built from other parts?"
 msgstr ""
 
-#: part/models.py:578
+#: part/models.py:630
 msgid "Can this part be used to build other parts?"
 msgstr ""
 
-#: part/models.py:580
+#: part/models.py:632
 msgid "Does this part have tracking for unique items?"
 msgstr ""
 
-#: part/models.py:582
+#: part/models.py:634
 msgid "Can this part be purchased from external suppliers?"
 msgstr ""
 
-#: part/models.py:584
+#: part/models.py:636
 msgid "Can this part be sold to customers?"
 msgstr ""
 
-#: part/models.py:586
+#: part/models.py:638
 msgid "Is this part active?"
 msgstr ""
 
-#: part/models.py:588
+#: part/models.py:640
 msgid "Is this a virtual part, such as a software product or license?"
 msgstr ""
 
-#: part/models.py:590
+#: part/models.py:642
 msgid "Part notes - supports Markdown formatting"
 msgstr ""
 
-#: part/models.py:592
+#: part/models.py:644
 msgid "Stored BOM checksum"
 msgstr ""
 
-#: part/models.py:1300
+#: part/models.py:1353
 msgid "Test templates can only be created for trackable parts"
 msgstr ""
 
-#: part/models.py:1317
+#: part/models.py:1370
 msgid "Test with this name already exists for this part"
 msgstr ""
 
-#: part/models.py:1336 templates/js/part.html:455 templates/js/stock.html:92
+#: part/models.py:1389 templates/js/part.html:521 templates/js/stock.html:92
 msgid "Test Name"
 msgstr ""
 
-#: part/models.py:1337
+#: part/models.py:1390
 msgid "Enter a name for the test"
 msgstr ""
 
-#: part/models.py:1342
+#: part/models.py:1395
 msgid "Test Description"
 msgstr ""
 
-#: part/models.py:1343
+#: part/models.py:1396
 msgid "Enter description for this test"
 msgstr ""
 
-#: part/models.py:1349
+#: part/models.py:1402
 msgid "Is this test required to pass?"
 msgstr ""
 
-#: part/models.py:1354 templates/js/part.html:472
+#: part/models.py:1407 templates/js/part.html:538
 msgid "Requires Value"
 msgstr ""
 
-#: part/models.py:1355
+#: part/models.py:1408
 msgid "Does this test require a value when adding a test result?"
 msgstr ""
 
-#: part/models.py:1360 templates/js/part.html:479
+#: part/models.py:1413 templates/js/part.html:545
 msgid "Requires Attachment"
 msgstr ""
 
-#: part/models.py:1361
+#: part/models.py:1414
 msgid "Does this test require a file attachment when adding a test result?"
 msgstr ""
 
-#: part/models.py:1394
+#: part/models.py:1447
 msgid "Parameter template name must be unique"
 msgstr ""
 
-#: part/models.py:1399
+#: part/models.py:1452
 msgid "Parameter Name"
 msgstr ""
 
-#: part/models.py:1401
+#: part/models.py:1454
 msgid "Parameter Units"
 msgstr ""
 
-#: part/models.py:1427
+#: part/models.py:1480
 msgid "Parent Part"
 msgstr ""
 
-#: part/models.py:1429
+#: part/models.py:1482
 msgid "Parameter Template"
 msgstr ""
 
-#: part/models.py:1431
+#: part/models.py:1484
 msgid "Parameter Value"
 msgstr ""
 
-#: part/models.py:1467
+#: part/models.py:1521
 msgid "Select parent part"
 msgstr ""
 
-#: part/models.py:1475
+#: part/models.py:1529
 msgid "Select part to be used in BOM"
 msgstr ""
 
-#: part/models.py:1481
+#: part/models.py:1535
 msgid "BOM quantity for this BOM item"
 msgstr ""
 
-#: part/models.py:1484
+#: part/models.py:1537
+msgid "This BOM item is optional"
+msgstr ""
+
+#: part/models.py:1540
 msgid "Estimated build wastage quantity (absolute or percentage)"
 msgstr ""
 
-#: part/models.py:1487
+#: part/models.py:1543
 msgid "BOM item reference"
 msgstr ""
 
-#: part/models.py:1490
+#: part/models.py:1546
 msgid "BOM item notes"
 msgstr ""
 
-#: part/models.py:1492
+#: part/models.py:1548
 msgid "BOM line checksum"
 msgstr ""
 
-#: part/models.py:1556 part/views.py:1310 part/views.py:1362
-#: stock/models.py:229
+#: part/models.py:1612 part/views.py:1310 part/views.py:1362
+#: stock/models.py:231
 msgid "Quantity must be integer value for trackable parts"
 msgstr ""
 
-#: part/models.py:1565
+#: part/models.py:1621
 msgid "BOM Item"
 msgstr ""
 
@@ -2237,14 +2250,14 @@ msgstr ""
 #: part/templates/part/allocation.html:45
 #: stock/templates/stock/item_base.html:8
 #: stock/templates/stock/item_base.html:58
-#: stock/templates/stock/item_base.html:226
+#: stock/templates/stock/item_base.html:238
 #: stock/templates/stock/stock_adjust.html:16 templates/js/build.html:112
-#: templates/js/stock.html:651
+#: templates/js/stock.html:660 templates/js/stock.html:896
 msgid "Stock Item"
 msgstr ""
 
 #: part/templates/part/allocation.html:20
-#: stock/templates/stock/item_base.html:180
+#: stock/templates/stock/item_base.html:192
 msgid "Build Order"
 msgstr ""
 
@@ -2360,91 +2373,95 @@ msgstr ""
 msgid "Each part must already exist in the database"
 msgstr ""
 
-#: part/templates/part/category.html:14
+#: part/templates/part/category.html:19
 msgid "All parts"
 msgstr ""
 
-#: part/templates/part/category.html:18 part/views.py:1935
+#: part/templates/part/category.html:23 part/views.py:1976
 msgid "Create new part category"
 msgstr ""
 
-#: part/templates/part/category.html:22
+#: part/templates/part/category.html:27
 msgid "Edit part category"
 msgstr ""
 
-#: part/templates/part/category.html:25
+#: part/templates/part/category.html:30
 msgid "Delete part category"
 msgstr ""
 
-#: part/templates/part/category.html:34 part/templates/part/category.html:73
+#: part/templates/part/category.html:39 part/templates/part/category.html:78
 msgid "Category Details"
 msgstr ""
 
-#: part/templates/part/category.html:39
+#: part/templates/part/category.html:44
 msgid "Category Path"
 msgstr ""
 
-#: part/templates/part/category.html:44
+#: part/templates/part/category.html:49
 msgid "Category Description"
 msgstr ""
 
-#: part/templates/part/category.html:57 part/templates/part/detail.html:64
+#: part/templates/part/category.html:62 part/templates/part/detail.html:64
 msgid "Keywords"
 msgstr ""
 
-#: part/templates/part/category.html:63
+#: part/templates/part/category.html:68
 msgid "Subcategories"
 msgstr ""
 
-#: part/templates/part/category.html:68
+#: part/templates/part/category.html:73
 msgid "Parts (Including subcategories)"
 msgstr ""
 
-#: part/templates/part/category.html:101
+#: part/templates/part/category.html:106
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:102 part/views.py:491
+#: part/templates/part/category.html:107 part/views.py:491
 msgid "Create new part"
 msgstr ""
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:111
 msgid "Set category"
 msgstr ""
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:111
 msgid "Set Category"
 msgstr ""
 
-#: part/templates/part/category.html:108
+#: part/templates/part/category.html:113
 msgid "Export Data"
 msgstr ""
 
-#: part/templates/part/category.html:154
+#: part/templates/part/category.html:162
 msgid "Create new location"
 msgstr ""
 
-#: part/templates/part/category.html:159 part/templates/part/category.html:188
+#: part/templates/part/category.html:167 part/templates/part/category.html:196
 msgid "New Category"
 msgstr ""
 
-#: part/templates/part/category.html:160
+#: part/templates/part/category.html:168
 msgid "Create new category"
 msgstr ""
 
-#: part/templates/part/category.html:189
+#: part/templates/part/category.html:197
 msgid "Create new Part Category"
 msgstr ""
 
-#: part/templates/part/category.html:195 stock/views.py:1213
+#: part/templates/part/category.html:203 stock/views.py:1314
 msgid "Create new Stock Location"
 msgstr ""
 
+#: part/templates/part/category_tabs.html:9
+msgid "Parametric Table"
+msgstr ""
+
 #: part/templates/part/detail.html:9
 msgid "Part Details"
 msgstr ""
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:82
+#: part/templates/part/detail.html:25 part/templates/part/part_base.html:85
 #: templates/js/part.html:112
 msgid "IPN"
 msgstr ""
@@ -2466,7 +2483,7 @@ msgid "Variant Of"
 msgstr ""
 
 #: part/templates/part/detail.html:70 part/templates/part/set_category.html:15
-#: templates/js/part.html:293
+#: templates/js/part.html:359
 msgid "Category"
 msgstr ""
 
@@ -2506,8 +2523,8 @@ msgstr ""
 msgid "Part is not a virtual part"
 msgstr ""
 
-#: part/templates/part/detail.html:145 stock/forms.py:244
-#: templates/js/table_filters.html:183
+#: part/templates/part/detail.html:145 stock/forms.py:248
+#: templates/js/table_filters.html:188
 msgid "Template"
 msgstr ""
 
@@ -2519,7 +2536,7 @@ msgstr ""
 msgid "Part is not a template part"
 msgstr ""
 
-#: part/templates/part/detail.html:154 templates/js/table_filters.html:195
+#: part/templates/part/detail.html:154 templates/js/table_filters.html:200
 msgid "Assembly"
 msgstr ""
 
@@ -2531,7 +2548,7 @@ msgstr ""
 msgid "Part cannot be assembled from other parts"
 msgstr ""
 
-#: part/templates/part/detail.html:163 templates/js/table_filters.html:199
+#: part/templates/part/detail.html:163 templates/js/table_filters.html:204
 msgid "Component"
 msgstr ""
 
@@ -2543,7 +2560,7 @@ msgstr ""
 msgid "Part cannot be used in assemblies"
 msgstr ""
 
-#: part/templates/part/detail.html:172 templates/js/table_filters.html:211
+#: part/templates/part/detail.html:172 templates/js/table_filters.html:216
 msgid "Trackable"
 msgstr ""
 
@@ -2563,7 +2580,7 @@ msgstr ""
 msgid "Part can be purchased from external suppliers"
 msgstr ""
 
-#: part/templates/part/detail.html:190 templates/js/table_filters.html:207
+#: part/templates/part/detail.html:190 templates/js/table_filters.html:212
 msgid "Salable"
 msgstr ""
 
@@ -2575,7 +2592,7 @@ msgstr ""
 msgid "Part cannot be sold to customers"
 msgstr ""
 
-#: part/templates/part/detail.html:199 templates/js/table_filters.html:178
+#: part/templates/part/detail.html:199 templates/js/table_filters.html:183
 msgid "Active"
 msgstr ""
 
@@ -2607,7 +2624,7 @@ msgstr ""
 msgid "New Parameter"
 msgstr ""
 
-#: part/templates/part/params.html:21 stock/models.py:1340
+#: part/templates/part/params.html:21 stock/models.py:1391
 #: templates/js/stock.html:112
 msgid "Value"
 msgstr ""
@@ -2636,70 +2653,70 @@ msgstr ""
 msgid "This part is a variant of"
 msgstr ""
 
-#: part/templates/part/part_base.html:33 templates/js/company.html:153
-#: templates/js/part.html:270
+#: part/templates/part/part_base.html:36 templates/js/company.html:153
+#: templates/js/part.html:336
 msgid "Inactive"
 msgstr ""
 
-#: part/templates/part/part_base.html:40
+#: part/templates/part/part_base.html:43
 msgid "Star this part"
 msgstr ""
 
-#: part/templates/part/part_base.html:46
-#: stock/templates/stock/item_base.html:78
-#: stock/templates/stock/location.html:22
-msgid "Barcode actions"
-msgstr ""
-
-#: part/templates/part/part_base.html:48
-#: stock/templates/stock/item_base.html:80
-#: stock/templates/stock/location.html:24
-msgid "Show QR Code"
-msgstr ""
-
 #: part/templates/part/part_base.html:49
 #: stock/templates/stock/item_base.html:81
-#: stock/templates/stock/location.html:25
+#: stock/templates/stock/location.html:27
+msgid "Barcode actions"
+msgstr ""
+
+#: part/templates/part/part_base.html:51
+#: stock/templates/stock/item_base.html:83
+#: stock/templates/stock/location.html:29
+msgid "Show QR Code"
+msgstr ""
+
+#: part/templates/part/part_base.html:52
+#: stock/templates/stock/item_base.html:84
+#: stock/templates/stock/location.html:30
 msgid "Print Label"
 msgstr ""
 
-#: part/templates/part/part_base.html:53
+#: part/templates/part/part_base.html:56
 msgid "Show pricing information"
 msgstr ""
 
-#: part/templates/part/part_base.html:67
+#: part/templates/part/part_base.html:70
 msgid "Part actions"
 msgstr ""
 
-#: part/templates/part/part_base.html:69
+#: part/templates/part/part_base.html:72
 msgid "Duplicate part"
 msgstr ""
 
-#: part/templates/part/part_base.html:70
+#: part/templates/part/part_base.html:73
 msgid "Edit part"
 msgstr ""
 
-#: part/templates/part/part_base.html:72
+#: part/templates/part/part_base.html:75
 msgid "Delete part"
 msgstr ""
 
-#: part/templates/part/part_base.html:111 templates/js/table_filters.html:65
+#: part/templates/part/part_base.html:114 templates/js/table_filters.html:65
 msgid "In Stock"
 msgstr ""
 
-#: part/templates/part/part_base.html:118
+#: part/templates/part/part_base.html:121
 msgid "Allocated to Build Orders"
 msgstr ""
 
-#: part/templates/part/part_base.html:125
+#: part/templates/part/part_base.html:128
 msgid "Allocated to Sales Orders"
 msgstr ""
 
-#: part/templates/part/part_base.html:147
+#: part/templates/part/part_base.html:150
 msgid "Can Build"
 msgstr ""
 
-#: part/templates/part/part_base.html:153
+#: part/templates/part/part_base.html:156
 msgid "Underway"
 msgstr ""
 
@@ -2743,8 +2760,8 @@ msgstr ""
 msgid "Part Stock"
 msgstr ""
 
-#: part/templates/part/stock_count.html:7 templates/js/bom.html:193
-#: templates/js/part.html:330
+#: part/templates/part/stock_count.html:7 templates/js/bom.html:197
+#: templates/js/part.html:396
 msgid "No Stock"
 msgstr ""
 
@@ -2784,7 +2801,7 @@ msgstr ""
 msgid "Used In"
 msgstr ""
 
-#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:270
+#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:282
 msgid "Tests"
 msgstr ""
 
@@ -2961,27 +2978,27 @@ msgstr ""
 msgid "Delete Part Parameter"
 msgstr ""
 
-#: part/views.py:1886
+#: part/views.py:1927
 msgid "Edit Part Category"
 msgstr ""
 
-#: part/views.py:1921
+#: part/views.py:1962
 msgid "Delete Part Category"
 msgstr ""
 
-#: part/views.py:1927
+#: part/views.py:1968
 msgid "Part category was deleted"
 msgstr ""
 
-#: part/views.py:1986
+#: part/views.py:2027
 msgid "Create BOM item"
 msgstr ""
 
-#: part/views.py:2052
+#: part/views.py:2093
 msgid "Edit BOM item"
 msgstr ""
 
-#: part/views.py:2100
+#: part/views.py:2141
 msgid "Confim BOM item deletion"
 msgstr ""
 
@@ -3013,267 +3030,295 @@ msgstr ""
 msgid "Asset file description"
 msgstr ""
 
-#: stock/forms.py:187
+#: stock/forms.py:191
 msgid "Label"
 msgstr ""
 
-#: stock/forms.py:188 stock/forms.py:244
+#: stock/forms.py:192 stock/forms.py:248
 msgid "Select test report template"
 msgstr ""
 
-#: stock/forms.py:252
+#: stock/forms.py:256
 msgid "Include stock items in sub locations"
 msgstr ""
 
-#: stock/forms.py:279
+#: stock/forms.py:291
+msgid "Stock item to install"
+msgstr ""
+
+#: stock/forms.py:298
+msgid "Stock quantity to assign"
+msgstr ""
+
+#: stock/forms.py:326
+msgid "Must not exceed available quantity"
+msgstr ""
+
+#: stock/forms.py:336
 msgid "Destination location for uninstalled items"
 msgstr ""
 
-#: stock/forms.py:281
+#: stock/forms.py:338
 msgid "Add transaction note (optional)"
 msgstr ""
 
-#: stock/forms.py:283
+#: stock/forms.py:340
 msgid "Confirm uninstall"
 msgstr ""
 
-#: stock/forms.py:283
+#: stock/forms.py:340
 msgid "Confirm removal of installed stock items"
 msgstr ""
 
-#: stock/forms.py:307
+#: stock/forms.py:364
 msgid "Destination"
 msgstr ""
 
-#: stock/forms.py:307
+#: stock/forms.py:364
 msgid "Destination stock location"
 msgstr ""
 
-#: stock/forms.py:309
+#: stock/forms.py:366
 msgid "Add note (required)"
 msgstr ""
 
-#: stock/forms.py:313 stock/views.py:795 stock/views.py:992
+#: stock/forms.py:370 stock/views.py:895 stock/views.py:1092
 msgid "Confirm stock adjustment"
 msgstr ""
 
-#: stock/forms.py:313
+#: stock/forms.py:370
 msgid "Confirm movement of stock items"
 msgstr ""
 
-#: stock/forms.py:315
+#: stock/forms.py:372
 msgid "Set Default Location"
 msgstr ""
 
-#: stock/forms.py:315
+#: stock/forms.py:372
 msgid "Set the destination as the default location for selected parts"
 msgstr ""
 
-#: stock/models.py:210
+#: stock/models.py:212
 msgid "StockItem with this serial number already exists"
 msgstr ""
 
-#: stock/models.py:246
+#: stock/models.py:248
 #, python-brace-format
 msgid "Part type ('{pf}') must be {pe}"
 msgstr ""
 
-#: stock/models.py:256 stock/models.py:265
+#: stock/models.py:258 stock/models.py:267
 msgid "Quantity must be 1 for item with a serial number"
 msgstr ""
 
-#: stock/models.py:257
+#: stock/models.py:259
 msgid "Serial number cannot be set if quantity greater than 1"
 msgstr ""
 
-#: stock/models.py:278
+#: stock/models.py:281
 msgid "Item cannot belong to itself"
 msgstr ""
 
-#: stock/models.py:311
+#: stock/models.py:287
+msgid "Item must have a build reference if is_building=True"
+msgstr ""
+
+#: stock/models.py:294
+msgid "Build reference does not point to the same part object"
+msgstr ""
+
+#: stock/models.py:327
 msgid "Parent Stock Item"
 msgstr ""
 
-#: stock/models.py:320
+#: stock/models.py:336
 msgid "Base part"
 msgstr ""
 
-#: stock/models.py:329
+#: stock/models.py:345
 msgid "Select a matching supplier part for this stock item"
 msgstr ""
 
-#: stock/models.py:334 stock/templates/stock/stock_app_base.html:7
+#: stock/models.py:350 stock/templates/stock/stock_app_base.html:7
 msgid "Stock Location"
 msgstr ""
 
-#: stock/models.py:337
+#: stock/models.py:353
 msgid "Where is this stock item located?"
 msgstr ""
 
-#: stock/models.py:342
+#: stock/models.py:358 stock/templates/stock/item_base.html:177
 msgid "Installed In"
 msgstr ""
 
-#: stock/models.py:345
+#: stock/models.py:361
 msgid "Is this item installed in another item?"
 msgstr ""
 
-#: stock/models.py:361
+#: stock/models.py:377
 msgid "Serial number for this item"
 msgstr ""
 
-#: stock/models.py:373
+#: stock/models.py:389
 msgid "Batch code for this stock item"
 msgstr ""
 
-#: stock/models.py:377
+#: stock/models.py:393
 msgid "Stock Quantity"
 msgstr ""
 
-#: stock/models.py:386
+#: stock/models.py:402
 msgid "Source Build"
 msgstr ""
 
-#: stock/models.py:388
+#: stock/models.py:404
 msgid "Build for this stock item"
 msgstr ""
 
-#: stock/models.py:395
+#: stock/models.py:415
 msgid "Source Purchase Order"
 msgstr ""
 
-#: stock/models.py:398
+#: stock/models.py:418
 msgid "Purchase order for this stock item"
 msgstr ""
 
-#: stock/models.py:404
+#: stock/models.py:424
 msgid "Destination Sales Order"
 msgstr ""
 
-#: stock/models.py:411
+#: stock/models.py:431
 msgid "Destination Build Order"
 msgstr ""
 
-#: stock/models.py:424
+#: stock/models.py:444
 msgid "Delete this Stock Item when stock is depleted"
 msgstr ""
 
-#: stock/models.py:434 stock/templates/stock/item_notes.html:14
+#: stock/models.py:454 stock/templates/stock/item_notes.html:14
 #: stock/templates/stock/item_notes.html:30
 msgid "Stock Item Notes"
 msgstr ""
 
-#: stock/models.py:485
+#: stock/models.py:505
 msgid "Assigned to Customer"
 msgstr ""
 
-#: stock/models.py:487
+#: stock/models.py:507
 msgid "Manually assigned to customer"
 msgstr ""
 
-#: stock/models.py:500
+#: stock/models.py:520
 msgid "Returned from customer"
 msgstr ""
 
-#: stock/models.py:502
+#: stock/models.py:522
 msgid "Returned to location"
 msgstr ""
 
-#: stock/models.py:626
-msgid "Installed in stock item"
+#: stock/models.py:650
+msgid "Installed into stock item"
 msgstr ""
 
-#: stock/models.py:655
+#: stock/models.py:658
+msgid "Installed stock item"
+msgstr ""
+
+#: stock/models.py:682
+msgid "Uninstalled stock item"
+msgstr ""
+
+#: stock/models.py:701
 msgid "Uninstalled into location"
 msgstr ""
 
-#: stock/models.py:745
+#: stock/models.py:796
 msgid "Part is not set as trackable"
 msgstr ""
 
-#: stock/models.py:751
+#: stock/models.py:802
 msgid "Quantity must be integer"
 msgstr ""
 
-#: stock/models.py:757
+#: stock/models.py:808
 #, python-brace-format
 msgid "Quantity must not exceed available stock quantity ({n})"
 msgstr ""
 
-#: stock/models.py:760
+#: stock/models.py:811
 msgid "Serial numbers must be a list of integers"
 msgstr ""
 
-#: stock/models.py:763
+#: stock/models.py:814
 msgid "Quantity does not match serial numbers"
 msgstr ""
 
-#: stock/models.py:773
+#: stock/models.py:824
 msgid "Serial numbers already exist: "
 msgstr ""
 
-#: stock/models.py:798
+#: stock/models.py:849
 msgid "Add serial number"
 msgstr ""
 
-#: stock/models.py:801
+#: stock/models.py:852
 #, python-brace-format
 msgid "Serialized {n} items"
 msgstr ""
 
-#: stock/models.py:912
+#: stock/models.py:963
 msgid "StockItem cannot be moved as it is not in stock"
 msgstr ""
 
-#: stock/models.py:1241
+#: stock/models.py:1292
 msgid "Tracking entry title"
 msgstr ""
 
-#: stock/models.py:1243
+#: stock/models.py:1294
 msgid "Entry notes"
 msgstr ""
 
-#: stock/models.py:1245
+#: stock/models.py:1296
 msgid "Link to external page for further information"
 msgstr ""
 
-#: stock/models.py:1305
+#: stock/models.py:1356
 msgid "Value must be provided for this test"
 msgstr ""
 
-#: stock/models.py:1311
+#: stock/models.py:1362
 msgid "Attachment must be uploaded for this test"
 msgstr ""
 
-#: stock/models.py:1328
+#: stock/models.py:1379
 msgid "Test"
 msgstr ""
 
-#: stock/models.py:1329
+#: stock/models.py:1380
 msgid "Test name"
 msgstr ""
 
-#: stock/models.py:1334
+#: stock/models.py:1385
 msgid "Result"
 msgstr ""
 
-#: stock/models.py:1335 templates/js/table_filters.html:111
+#: stock/models.py:1386 templates/js/table_filters.html:111
 msgid "Test result"
 msgstr ""
 
-#: stock/models.py:1341
+#: stock/models.py:1392
 msgid "Test output value"
 msgstr ""
 
-#: stock/models.py:1347
+#: stock/models.py:1398
 msgid "Attachment"
 msgstr ""
 
-#: stock/models.py:1348
+#: stock/models.py:1399
 msgid "Test result attachment"
 msgstr ""
 
-#: stock/models.py:1354
+#: stock/models.py:1405
 msgid "Test notes"
 msgstr ""
 
@@ -3312,102 +3357,106 @@ msgid ""
 "This stock item will be automatically deleted when all stock is depleted."
 msgstr ""
 
-#: stock/templates/stock/item_base.html:83 templates/js/barcode.html:283
+#: stock/templates/stock/item_base.html:86 templates/js/barcode.html:283
 #: templates/js/barcode.html:288
 msgid "Unlink Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:85
+#: stock/templates/stock/item_base.html:88
 msgid "Link Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:91
+#: stock/templates/stock/item_base.html:94
 msgid "Stock adjustment actions"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:95
-#: stock/templates/stock/location.html:33 templates/stock_table.html:14
+#: stock/templates/stock/item_base.html:98
+#: stock/templates/stock/location.html:38 templates/stock_table.html:15
 msgid "Count stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:96 templates/stock_table.html:12
+#: stock/templates/stock/item_base.html:99 templates/stock_table.html:13
 msgid "Add stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:97 templates/stock_table.html:13
+#: stock/templates/stock/item_base.html:100 templates/stock_table.html:14
 msgid "Remove stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:99
+#: stock/templates/stock/item_base.html:102
 msgid "Transfer stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:101
+#: stock/templates/stock/item_base.html:104
 msgid "Serialize stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:105
+#: stock/templates/stock/item_base.html:108
 msgid "Assign to customer"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:108
+#: stock/templates/stock/item_base.html:111
 msgid "Return to stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:114
-#: stock/templates/stock/location.html:30
+#: stock/templates/stock/item_base.html:115 templates/js/stock.html:933
+msgid "Uninstall stock item"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:115
+msgid "Uninstall"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:122
+#: stock/templates/stock/location.html:35
 msgid "Stock actions"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:118
+#: stock/templates/stock/item_base.html:126
 msgid "Convert to variant"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:120
+#: stock/templates/stock/item_base.html:128
 msgid "Duplicate stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:121
+#: stock/templates/stock/item_base.html:129
 msgid "Edit stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:123
+#: stock/templates/stock/item_base.html:131
 msgid "Delete stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:127
+#: stock/templates/stock/item_base.html:135
 msgid "Generate test report"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:135
+#: stock/templates/stock/item_base.html:143
 msgid "Stock Item Details"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:168
-msgid "Belongs To"
-msgstr ""
-
-#: stock/templates/stock/item_base.html:190
+#: stock/templates/stock/item_base.html:202
 msgid "No location set"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:197
+#: stock/templates/stock/item_base.html:209
 msgid "Unique Identifier"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:225
+#: stock/templates/stock/item_base.html:237
 msgid "Parent Item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:250
+#: stock/templates/stock/item_base.html:262
 msgid "Last Updated"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:255
+#: stock/templates/stock/item_base.html:267
 msgid "Last Stocktake"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:259
+#: stock/templates/stock/item_base.html:271
 msgid "No stocktake performed"
 msgstr ""
 
@@ -3423,29 +3472,32 @@ msgstr ""
 msgid "Are you sure you want to delete this stock item?"
 msgstr ""
 
+#: stock/templates/stock/item_install.html:7
+msgid "Install another StockItem into this item."
+msgstr ""
+
+#: stock/templates/stock/item_install.html:10
+msgid "Stock items can only be installed if they meet the following criteria"
+msgstr ""
+
+#: stock/templates/stock/item_install.html:13
+msgid "The StockItem links to a Part which is in the BOM for this StockItem"
+msgstr ""
+
+#: stock/templates/stock/item_install.html:14
+msgid "The StockItem is currently in stock"
+msgstr ""
+
 #: stock/templates/stock/item_installed.html:10
 msgid "Installed Stock Items"
 msgstr ""
 
-#: stock/templates/stock/item_installed.html:18
-msgid "Uninstall selected stock items"
+#: stock/templates/stock/item_serialize.html:5
+msgid "Create serialized items from this stock item."
 msgstr ""
 
-#: stock/templates/stock/item_installed.html:18
-msgid "Uninstall"
-msgstr ""
-
-#: stock/templates/stock/item_installed.html:35
-msgid "No stock items installed"
-msgstr ""
-
-#: stock/templates/stock/item_installed.html:48 templates/js/part.html:209
-#: templates/js/stock.html:409
-msgid "Select"
-msgstr ""
-
-#: stock/templates/stock/item_installed.html:131
-msgid "Uninstall item"
+#: stock/templates/stock/item_serialize.html:7
+msgid "Select quantity to serialize, and unique serial numbers."
 msgstr ""
 
 #: stock/templates/stock/item_tests.html:10 stock/templates/stock/tabs.html:13
@@ -3464,54 +3516,54 @@ msgstr ""
 msgid "Test Report"
 msgstr ""
 
-#: stock/templates/stock/location.html:13
+#: stock/templates/stock/location.html:18
 msgid "All stock items"
 msgstr ""
 
-#: stock/templates/stock/location.html:26
+#: stock/templates/stock/location.html:31
 msgid "Check-in Items"
 msgstr ""
 
-#: stock/templates/stock/location.html:37
+#: stock/templates/stock/location.html:42
 msgid "Location actions"
 msgstr ""
 
-#: stock/templates/stock/location.html:39
+#: stock/templates/stock/location.html:44
 msgid "Edit location"
 msgstr ""
 
-#: stock/templates/stock/location.html:40
+#: stock/templates/stock/location.html:45
 msgid "Delete location"
 msgstr ""
 
-#: stock/templates/stock/location.html:48
+#: stock/templates/stock/location.html:53
 msgid "Location Details"
 msgstr ""
 
-#: stock/templates/stock/location.html:53
+#: stock/templates/stock/location.html:58
 msgid "Location Path"
 msgstr ""
 
-#: stock/templates/stock/location.html:58
+#: stock/templates/stock/location.html:63
 msgid "Location Description"
 msgstr ""
 
-#: stock/templates/stock/location.html:63
+#: stock/templates/stock/location.html:68
 msgid "Sublocations"
 msgstr ""
 
-#: stock/templates/stock/location.html:68
-#: stock/templates/stock/location.html:83
+#: stock/templates/stock/location.html:73
+#: stock/templates/stock/location.html:88
 #: templates/InvenTree/search_stock_items.html:6 templates/stats.html:21
 #: templates/stats.html:30
 msgid "Stock Items"
 msgstr ""
 
-#: stock/templates/stock/location.html:73
+#: stock/templates/stock/location.html:78
 msgid "Stock Details"
 msgstr ""
 
-#: stock/templates/stock/location.html:78
+#: stock/templates/stock/location.html:83
 #: templates/InvenTree/search_stock_location.html:6 templates/stats.html:25
 msgid "Stock Locations"
 msgstr ""
@@ -3524,7 +3576,7 @@ msgstr ""
 msgid "The following stock items will be uninstalled"
 msgstr ""
 
-#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1186
+#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1287
 msgid "Convert Stock Item"
 msgstr ""
 
@@ -3636,130 +3688,134 @@ msgstr ""
 msgid "Stock Item QR Code"
 msgstr ""
 
-#: stock/views.py:699
+#: stock/views.py:700
+msgid "Install Stock Item"
+msgstr ""
+
+#: stock/views.py:799
 msgid "Uninstall Stock Items"
 msgstr ""
 
-#: stock/views.py:806
+#: stock/views.py:906
 msgid "Uninstalled stock items"
 msgstr ""
 
-#: stock/views.py:831
+#: stock/views.py:931
 msgid "Adjust Stock"
 msgstr ""
 
-#: stock/views.py:940
+#: stock/views.py:1040
 msgid "Move Stock Items"
 msgstr ""
 
-#: stock/views.py:941
+#: stock/views.py:1041
 msgid "Count Stock Items"
 msgstr ""
 
-#: stock/views.py:942
+#: stock/views.py:1042
 msgid "Remove From Stock"
 msgstr ""
 
-#: stock/views.py:943
+#: stock/views.py:1043
 msgid "Add Stock Items"
 msgstr ""
 
-#: stock/views.py:944
+#: stock/views.py:1044
 msgid "Delete Stock Items"
 msgstr ""
 
-#: stock/views.py:972
+#: stock/views.py:1072
 msgid "Must enter integer value"
 msgstr ""
 
-#: stock/views.py:977
+#: stock/views.py:1077
 msgid "Quantity must be positive"
 msgstr ""
 
-#: stock/views.py:984
+#: stock/views.py:1084
 #, python-brace-format
 msgid "Quantity must not exceed {x}"
 msgstr ""
 
-#: stock/views.py:1063
+#: stock/views.py:1163
 #, python-brace-format
 msgid "Added stock to {n} items"
 msgstr ""
 
-#: stock/views.py:1078
+#: stock/views.py:1178
 #, python-brace-format
 msgid "Removed stock from {n} items"
 msgstr ""
 
-#: stock/views.py:1091
+#: stock/views.py:1191
 #, python-brace-format
 msgid "Counted stock for {n} items"
 msgstr ""
 
-#: stock/views.py:1119
+#: stock/views.py:1219
 msgid "No items were moved"
 msgstr ""
 
-#: stock/views.py:1122
+#: stock/views.py:1222
 #, python-brace-format
 msgid "Moved {n} items to {dest}"
 msgstr ""
 
-#: stock/views.py:1141
+#: stock/views.py:1241
 #, python-brace-format
 msgid "Deleted {n} stock items"
 msgstr ""
 
-#: stock/views.py:1153
+#: stock/views.py:1253
 msgid "Edit Stock Item"
 msgstr ""
 
-#: stock/views.py:1234
+#: stock/views.py:1335
 msgid "Serialize Stock"
 msgstr ""
 
-#: stock/views.py:1426
+#: stock/views.py:1527
 msgid "Duplicate Stock Item"
 msgstr ""
 
-#: stock/views.py:1492
+#: stock/views.py:1593
 msgid "Invalid quantity"
 msgstr ""
 
-#: stock/views.py:1495
+#: stock/views.py:1596
 msgid "Quantity cannot be less than zero"
 msgstr ""
 
-#: stock/views.py:1499
+#: stock/views.py:1600
 msgid "Invalid part selection"
 msgstr ""
 
-#: stock/views.py:1548
+#: stock/views.py:1649
 #, python-brace-format
 msgid "Created {n} new stock items"
 msgstr ""
 
-#: stock/views.py:1567 stock/views.py:1583
+#: stock/views.py:1668 stock/views.py:1684
 msgid "Created new stock item"
 msgstr ""
 
-#: stock/views.py:1602
+#: stock/views.py:1703
 msgid "Delete Stock Location"
 msgstr ""
 
-#: stock/views.py:1615
+#: stock/views.py:1716
 msgid "Delete Stock Item"
 msgstr ""
 
-#: stock/views.py:1626
+#: stock/views.py:1727
 msgid "Delete Stock Tracking Entry"
 msgstr ""
 
-#: stock/views.py:1643
+#: stock/views.py:1744
 msgid "Edit Stock Tracking Entry"
 msgstr ""
 
-#: stock/views.py:1652
+#: stock/views.py:1753
 msgid "Add Stock Tracking Entry"
 msgstr ""
 
@@ -3787,15 +3843,19 @@ msgstr ""
 msgid "Search Results"
 msgstr ""
 
-#: templates/InvenTree/search.html:22
-msgid "No results found"
+#: templates/InvenTree/search.html:24
+msgid "No results found for "
 msgstr ""
 
-#: templates/InvenTree/search.html:181 templates/js/stock.html:521
+#: templates/InvenTree/search.html:42
+msgid "Enter a search query"
+msgstr ""
+
+#: templates/InvenTree/search.html:191 templates/js/stock.html:527
 msgid "Shipped to customer"
 msgstr ""
 
-#: templates/InvenTree/search.html:184 templates/js/stock.html:528
+#: templates/InvenTree/search.html:194 templates/js/stock.html:537
 msgid "No stock location set"
 msgstr ""
 
@@ -3974,31 +4034,35 @@ msgstr ""
 msgid "Open subassembly"
 msgstr ""
 
-#: templates/js/bom.html:184 templates/js/build.html:119
+#: templates/js/bom.html:173
+msgid "Optional"
+msgstr ""
+
+#: templates/js/bom.html:188 templates/js/build.html:119
 msgid "Available"
 msgstr ""
 
-#: templates/js/bom.html:209
+#: templates/js/bom.html:213
 msgid "No pricing available"
 msgstr ""
 
-#: templates/js/bom.html:228
+#: templates/js/bom.html:232
 msgid "Actions"
 msgstr ""
 
-#: templates/js/bom.html:236
+#: templates/js/bom.html:240
 msgid "Validate BOM Item"
 msgstr ""
 
-#: templates/js/bom.html:238
+#: templates/js/bom.html:242
 msgid "This line has been validated"
 msgstr ""
 
-#: templates/js/bom.html:240
+#: templates/js/bom.html:244
 msgid "Edit BOM Item"
 msgstr ""
 
-#: templates/js/bom.html:242
+#: templates/js/bom.html:246
 msgid "Delete BOM Item"
 msgstr ""
 
@@ -4026,11 +4090,11 @@ msgstr ""
 msgid "No supplier parts found"
 msgstr ""
 
-#: templates/js/company.html:145 templates/js/part.html:248
+#: templates/js/company.html:145 templates/js/part.html:314
 msgid "Template part"
 msgstr ""
 
-#: templates/js/company.html:149 templates/js/part.html:252
+#: templates/js/company.html:149 templates/js/part.html:318
 msgid "Assembled part"
 msgstr ""
 
@@ -4042,7 +4106,7 @@ msgstr ""
 msgid "No purchase orders found"
 msgstr ""
 
-#: templates/js/order.html:172 templates/js/stock.html:633
+#: templates/js/order.html:172 templates/js/stock.html:642
 msgid "Date"
 msgstr ""
 
@@ -4058,51 +4122,56 @@ msgstr ""
 msgid "No variants found"
 msgstr ""
 
-#: templates/js/part.html:256
-msgid "Starred part"
-msgstr ""
-
-#: templates/js/part.html:260
-msgid "Salable part"
-msgstr ""
-
-#: templates/js/part.html:299
-msgid "No category"
-msgstr ""
-
-#: templates/js/part.html:317 templates/js/table_filters.html:191
-msgid "Low stock"
-msgstr ""
-
-#: templates/js/part.html:326
-msgid "Building"
-msgstr ""
-
-#: templates/js/part.html:345
+#: templates/js/part.html:223 templates/js/part.html:411
 msgid "No parts found"
 msgstr ""
 
-#: templates/js/part.html:405
+#: templates/js/part.html:275 templates/js/stock.html:409
+#: templates/js/stock.html:965
+msgid "Select"
+msgstr ""
+
+#: templates/js/part.html:322
+msgid "Starred part"
+msgstr ""
+
+#: templates/js/part.html:326
+msgid "Salable part"
+msgstr ""
+
+#: templates/js/part.html:365
+msgid "No category"
+msgstr ""
+
+#: templates/js/part.html:383 templates/js/table_filters.html:196
+msgid "Low stock"
+msgstr ""
+
+#: templates/js/part.html:392
+msgid "Building"
+msgstr ""
+
+#: templates/js/part.html:471
 msgid "YES"
 msgstr ""
 
-#: templates/js/part.html:407
+#: templates/js/part.html:473
 msgid "NO"
 msgstr ""
 
-#: templates/js/part.html:441
+#: templates/js/part.html:507
 msgid "No test templates matching query"
 msgstr ""
 
-#: templates/js/part.html:492 templates/js/stock.html:63
+#: templates/js/part.html:558 templates/js/stock.html:63
 msgid "Edit test result"
 msgstr ""
 
-#: templates/js/part.html:493 templates/js/stock.html:64
+#: templates/js/part.html:559 templates/js/stock.html:64
 msgid "Delete test result"
 msgstr ""
 
-#: templates/js/part.html:499
+#: templates/js/part.html:565
 msgid "This test is defined for a parent part"
 msgstr ""
 
@@ -4146,42 +4215,62 @@ msgstr ""
 msgid "Stock item has been assigned to customer"
 msgstr ""
 
-#: templates/js/stock.html:474
+#: templates/js/stock.html:475
 msgid "Stock item was assigned to a build order"
 msgstr ""
 
-#: templates/js/stock.html:476
+#: templates/js/stock.html:477
 msgid "Stock item was assigned to a sales order"
 msgstr ""
 
-#: templates/js/stock.html:483
+#: templates/js/stock.html:482
+msgid "Stock item has been installed in another item"
+msgstr ""
+
+#: templates/js/stock.html:489
 msgid "Stock item has been rejected"
 msgstr ""
 
-#: templates/js/stock.html:487
+#: templates/js/stock.html:493
 msgid "Stock item is lost"
 msgstr ""
 
-#: templates/js/stock.html:491 templates/js/table_filters.html:60
+#: templates/js/stock.html:497 templates/js/table_filters.html:60
 msgid "Depleted"
 msgstr ""
 
-#: templates/js/stock.html:516
+#: templates/js/stock.html:522
 msgid "Installed in Stock Item "
 msgstr ""
 
-#: templates/js/stock.html:699
+#: templates/js/stock.html:530
+msgid "Assigned to sales order"
+msgstr ""
+
+#: templates/js/stock.html:708
 msgid "No user information"
 msgstr ""
 
-#: templates/js/stock.html:783
+#: templates/js/stock.html:792
 msgid "Create New Part"
 msgstr ""
 
-#: templates/js/stock.html:795
+#: templates/js/stock.html:804
 msgid "Create New Location"
 msgstr ""
 
+#: templates/js/stock.html:903
+msgid "Serial"
+msgstr ""
+
+#: templates/js/stock.html:996 templates/js/table_filters.html:70
+msgid "Installed"
+msgstr ""
+
+#: templates/js/stock.html:1021
+msgid "Install item"
+msgstr ""
+
 #: templates/js/table_filters.html:19 templates/js/table_filters.html:80
 msgid "Is Serialized"
 msgstr ""
@@ -4243,10 +4332,6 @@ msgstr ""
 msgid "Show items which are in stock"
 msgstr ""
 
-#: templates/js/table_filters.html:70
-msgid "Installed"
-msgstr ""
-
 #: templates/js/table_filters.html:71
 msgid "Show stock items which are installed in another item"
 msgstr ""
@@ -4283,19 +4368,27 @@ msgstr ""
 msgid "Include parts in subcategories"
 msgstr ""
 
+#: templates/js/table_filters.html:178
+msgid "Has IPN"
+msgstr ""
+
 #: templates/js/table_filters.html:179
+msgid "Part has internal part number"
+msgstr ""
+
+#: templates/js/table_filters.html:184
 msgid "Show active parts"
 msgstr ""
 
-#: templates/js/table_filters.html:187
+#: templates/js/table_filters.html:192
 msgid "Stock available"
 msgstr ""
 
-#: templates/js/table_filters.html:203
+#: templates/js/table_filters.html:208
 msgid "Starred"
 msgstr ""
 
-#: templates/js/table_filters.html:215
+#: templates/js/table_filters.html:220
 msgid "Purchasable"
 msgstr ""
 
@@ -4339,42 +4432,42 @@ msgstr ""
 msgid "Search"
 msgstr ""
 
-#: templates/stock_table.html:5
+#: templates/stock_table.html:6
 msgid "Export Stock Information"
 msgstr ""
 
-#: templates/stock_table.html:12
+#: templates/stock_table.html:13
 msgid "Add to selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:13
+#: templates/stock_table.html:14
 msgid "Remove from selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:14
+#: templates/stock_table.html:15
 msgid "Stocktake selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:16
 msgid "Move selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:16
 msgid "Move stock"
 msgstr ""
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:17
 msgid "Order selected items"
 msgstr ""
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:17
 msgid "Order stock"
 msgstr ""
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:18
 msgid "Delete selected items"
 msgstr ""
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:18
 msgid "Delete Stock"
 msgstr ""

From 095ef51991b1f857bf52d5b3f2cff11a51164426 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 08:29:36 +1100
Subject: [PATCH 48/77] Cleanup unit testing

---
 InvenTree/users/tests.py | 21 ++++++++++++++-------
 1 file changed, 14 insertions(+), 7 deletions(-)

diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index ad3a6ec5ae..d87649e2db 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -59,8 +59,6 @@ class RuleSetModelTest(TestCase):
             table_name = model.objects.model._meta.db_table
             available_tables.append(table_name)
 
-        errors = 0
-
         assigned_models = []
 
         # Now check that each defined model is a valid table name
@@ -72,10 +70,6 @@ class RuleSetModelTest(TestCase):
 
                 assigned_models.append(m)
 
-                if m not in available_tables:
-                    print("{n} is not a valid database table".format(n=m))
-                    errors += 1
-
         missing_models = []
 
         for model in available_tables:
@@ -87,5 +81,18 @@ class RuleSetModelTest(TestCase):
             for m in missing_models:
                 print("-", m)
 
-        self.assertEqual(errors, 0)
+        extra_models = []
+
+        defined_models = assigned_models + RuleSet.RULESET_IGNORE
+
+        for model in defined_models:
+            if model not in available_tables:
+                extra_models.append(model)
+
+        if len(extra_models) > 0:
+            print("The following RuleSet permissions do not match a database model:")
+            for m in extra_models:
+                print("-", m)
+
         self.assertEqual(len(missing_models), 0)
+        self.assertEqual(len(extra_models), 0)

From 898c604b3b0d01094b715acadae76e35bdd15b65 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 08:55:15 +1100
Subject: [PATCH 49/77] Fix incorrect permission names

- Uses the app_model name, *NOT* the name of the database table
- Adds extra tests to ensure that permissions get assigned and removed correctly
---
 InvenTree/users/models.py | 19 ++++++----
 InvenTree/users/tests.py  | 79 ++++++++++++++++++++++++++++++++++-----
 2 files changed, 80 insertions(+), 18 deletions(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 8f349e2f18..09f2a046d1 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -72,8 +72,8 @@ class RuleSet(models.Model):
         ],
         'purchase_order': [
             'company_company',
-            'part_supplierpart',
-            'part_supplierpricebreak',
+            'company_supplierpart',
+            'company_supplierpricebreak',
             'order_purchaseorder',
             'order_purchaseorderattachment',
             'order_purchaseorderlineitem',
@@ -90,9 +90,9 @@ class RuleSet(models.Model):
     # Database models we ignore permission sets for
     RULESET_IGNORE = [
         # Core django models (not user configurable)
-        'django_admin_log',
-        'django_content_type',
-        'django_session',
+        'admin_logentry',
+        'contenttypes_contenttype',
+        'sessions_session',
 
         # Models which currently do not require permissions
         'common_colortheme',
@@ -275,9 +275,12 @@ def update_group_roles(group, debug=False):
 
         (permission_name, model) = perm.split('_')
 
-        content_type = ContentType.objects.get(app_label=app, model=model)
-
-        permission = Permission.objects.get(content_type=content_type, codename=perm)
+        try:
+            content_type = ContentType.objects.get(app_label=app, model=model)
+            permission = Permission.objects.get(content_type=content_type, codename=perm)
+        except ContentType.DoesNotExist:
+            print(f"Error: Could not find permission matching '{permission_string}'")
+            permission = None
 
         return permission
 
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index d87649e2db..d14ffc4950 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 
 from django.test import TestCase
 from django.apps import apps
+from django.contrib.auth.models import Group
 
 from users.models import RuleSet
 
@@ -53,13 +54,15 @@ class RuleSetModelTest(TestCase):
 
         available_models = apps.get_models()
 
-        available_tables = []
+        available_tables = set()
 
+        # Extract each available database model and construct a formatted string
         for model in available_models:
-            table_name = model.objects.model._meta.db_table
-            available_tables.append(table_name)
+            label = model.objects.model._meta.label
+            label = label.replace('.', '_').lower()
+            available_tables.add(label)
 
-        assigned_models = []
+        assigned_models = set()
 
         # Now check that each defined model is a valid table name
         for key in RuleSet.RULESET_MODELS.keys():
@@ -68,26 +71,32 @@ class RuleSetModelTest(TestCase):
 
             for m in models:
 
-                assigned_models.append(m)
+                assigned_models.add(m)
 
-        missing_models = []
+        missing_models = set()
 
         for model in available_tables:
             if model not in assigned_models and model not in RuleSet.RULESET_IGNORE:
-                missing_models.append(model)
+                missing_models.add(model)
 
         if len(missing_models) > 0:
             print("The following database models are not covered by the defined RuleSet permissions:")
             for m in missing_models:
                 print("-", m)
 
-        extra_models = []
+        extra_models = set()
 
-        defined_models = assigned_models + RuleSet.RULESET_IGNORE
+        defined_models = set()
+        
+        for model in assigned_models:
+            defined_models.add(model)
+
+        for model in RuleSet.RULESET_IGNORE:
+            defined_models.add(model)
 
         for model in defined_models:
             if model not in available_tables:
-                extra_models.append(model)
+                extra_models.add(model)
 
         if len(extra_models) > 0:
             print("The following RuleSet permissions do not match a database model:")
@@ -96,3 +105,53 @@ class RuleSetModelTest(TestCase):
 
         self.assertEqual(len(missing_models), 0)
         self.assertEqual(len(extra_models), 0)
+
+    def test_permission_assign(self):
+        """
+        Test that the permission assigning works!
+        """
+
+        # Create a new group
+        group = Group.objects.create(name="Test group")
+
+        rulesets = group.rule_sets.all()
+
+        # Rulesets should have been created automatically for this group
+        self.assertEqual(rulesets.count(), len(RuleSet.RULESET_CHOICES))
+
+        # Check that all permissions have been assigned permissions?
+        permission_set = set()
+
+        for models in RuleSet.RULESET_MODELS.values():
+
+            for model in models:
+                permission_set.add(model)
+
+        # Every ruleset by default sets one permission, the "view" permission set
+        self.assertEqual(group.permissions.count(), len(permission_set))
+
+        # Add some more rules
+        for rule in rulesets:
+            rule.can_add = True
+            rule.can_change = True
+
+            rule.save()
+
+        group.save()
+
+        # There should now be three permissions for each rule set
+        self.assertEqual(group.permissions.count(), 3 * len(permission_set))
+
+        # Now remove *all* permissions
+        for rule in rulesets:
+            rule.can_view = False
+            rule.can_add = False
+            rule.can_change = False
+            rule.can_delete = False
+
+            rule.save()
+
+        group.save()
+
+        # There should now not be any permissions assigned to this group
+        self.assertEqual(group.permissions.count(), 0)

From 806a7f961de42a906a4be49b727ba5ce85001f15 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 22:57:05 +1100
Subject: [PATCH 50/77] Fixes for role permissions

- Fixed a strange interaction if multiple rulesets referred to the same models
- Order of operations was incorrect.
- Now is good? Yes!
---
 InvenTree/users/models.py | 24 +++++++++++++++++++-----
 1 file changed, 19 insertions(+), 5 deletions(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 09f2a046d1..5fe86e15fa 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -160,6 +160,15 @@ class RuleSet(models.Model):
 
     def save(self, *args, **kwargs):
 
+        # It does not make sense to be able to change / create something,
+        # but not be able to view it!
+
+        if self.can_add or self.can_change or self.can_delete:
+            self.can_view = True
+
+        if self.can_add or self.can_delete:
+            self.can_change = True
+
         super().save(*args, **kwargs)
 
     def get_models(self):
@@ -227,16 +236,13 @@ def update_group_roles(group, debug=False):
             if permission_string in permissions_to_delete:
                 permissions_to_delete.remove(permission_string)
 
-            if permission_string not in group_permissions:
-                permissions_to_add.add(permission_string)
+            permissions_to_add.add(permission_string)
 
         else:
 
             # A forbidden action will be ignored if we have already allowed it
             if permission_string not in permissions_to_add:
-
-                if permission_string in group_permissions:
-                    permissions_to_delete.add(permission_string)
+                permissions_to_delete.add(permission_string)
 
     # Get all the rulesets associated with this group
     for r in RuleSet.RULESET_CHOICES:
@@ -287,6 +293,10 @@ def update_group_roles(group, debug=False):
     # Add any required permissions to the group
     for perm in permissions_to_add:
         
+        # Ignore if permission is already in the group
+        if perm in group_permissions:
+            continue
+
         permission = get_permission_object(perm)
 
         group.permissions.add(permission)
@@ -297,6 +307,10 @@ def update_group_roles(group, debug=False):
     # Remove any extra permissions from the group
     for perm in permissions_to_delete:
 
+        # Ignore if the permission is not already assigned
+        if perm not in group_permissions:
+            continue
+
         permission = get_permission_object(perm)
 
         group.permissions.remove(permission)

From 3e9c7cda21dd37352039ae8a701b5292f3e6140d Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 23:11:55 +1100
Subject: [PATCH 51/77] Change what elements the user can see on the index
 page, based on permissions!

---
 InvenTree/templates/InvenTree/index.html | 20 ++++++++++++++------
 1 file changed, 14 insertions(+), 6 deletions(-)

diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index b0c2d0ae02..a9e4ae92ea 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -9,18 +9,26 @@ InvenTree | Index
 <hr>
 
 <div class='col-sm-6'>
+    {% if perms.part.view_part %}
     {% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
     {% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
-    {% include "InvenTree/low_stock.html" with collapse_id="order" %}
-    {% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
-
+    {% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
+    {% endif %}
+    {% if perms.build.view_build %}
+    {% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
+    {% endif %}
 </div>
 <div class='col-sm-6'>
-    {% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
-    {% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
+    {% if perms.stock.view_stockitem %}
+    {% include "InvenTree/low_stock.html" with collapse_id="order" %}
     {% include "InvenTree/required_stock_build.html" with collapse_id="stock_to_build" %}
+    {% endif %}
+    {% if perms.order.view_purchaseorder %}
+    {% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
+    {% endif %}
+    {% if perms.order.view_salesorder %}
     {% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
-
+    {% endif %}
 </div>
 
 {% endblock %}

From 4d49cb029f3873172e323635845f2b631d81f186 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 23:49:32 +1100
Subject: [PATCH 52/77] Change part views to require permissions

Also adds custom 403 page
---
 InvenTree/part/views.py                  | 93 ++++++++++++++++++++++--
 InvenTree/templates/403.html             | 18 +++++
 InvenTree/templates/InvenTree/index.html |  4 +-
 3 files changed, 108 insertions(+), 7 deletions(-)
 create mode 100644 InvenTree/templates/403.html

diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index dc8d07f5cd..84628eff04 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -12,6 +12,7 @@ from django.shortcuts import HttpResponseRedirect
 from django.utils.translation import gettext_lazy as _
 from django.urls import reverse, reverse_lazy
 from django.views.generic import DetailView, ListView, FormView, UpdateView
+from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.forms.models import model_to_dict
 from django.forms import HiddenInput, CheckboxInput
 from django.conf import settings
@@ -42,12 +43,13 @@ from InvenTree.views import QRCodeView
 from InvenTree.helpers import DownloadFile, str2bool
 
 
-class PartIndex(ListView):
+class PartIndex(PermissionRequiredMixin, ListView):
     """ View for displaying list of Part objects
     """
     model = Part
     template_name = 'part/category.html'
     context_object_name = 'parts'
+    permission_required = ('part.view_part', 'part.view_partcategory')
 
     def get_queryset(self):
         return Part.objects.all().select_related('category')
@@ -76,6 +78,8 @@ class PartAttachmentCreate(AjaxCreateView):
     ajax_form_title = _("Add part attachment")
     ajax_template_name = "modal_form.html"
 
+    permission_required = 'part.create_partattachment'
+
     def post_save(self):
         """ Record the user that uploaded the attachment """
         self.object.user = self.request.user
@@ -123,6 +127,8 @@ class PartAttachmentEdit(AjaxUpdateView):
     form_class = part_forms.EditPartAttachmentForm
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Edit attachment')
+
+    permission_required = 'part.change_partattachment'
     
     def get_data(self):
         return {
@@ -145,6 +151,8 @@ class PartAttachmentDelete(AjaxDeleteView):
     ajax_template_name = "attachment_delete.html"
     context_object_name = "attachment"
 
+    permission_required = 'part.delete_partattachment'
+
     def get_data(self):
         return {
             'danger': _('Deleted part attachment')
@@ -157,6 +165,8 @@ class PartTestTemplateCreate(AjaxCreateView):
     model = PartTestTemplate
     form_class = part_forms.EditPartTestTemplateForm
     ajax_form_title = _("Create Test Template")
+
+    permission_required = 'part.create_parttesttemplate'
     
     def get_initial(self):
 
@@ -185,6 +195,8 @@ class PartTestTemplateEdit(AjaxUpdateView):
     form_class = part_forms.EditPartTestTemplateForm
     ajax_form_title = _("Edit Test Template")
 
+    permission_required = 'part.change_parttesttemplate'
+
     def get_form(self):
 
         form = super().get_form()
@@ -199,6 +211,8 @@ class PartTestTemplateDelete(AjaxDeleteView):
     model = PartTestTemplate
     ajax_form_title = _("Delete Test Template")
 
+    permission_required = 'part.delete_parttesttemplate'
+
 
 class PartSetCategory(AjaxUpdateView):
     """ View for settings the part category for multiple parts at once """
@@ -207,6 +221,8 @@ class PartSetCategory(AjaxUpdateView):
     ajax_form_title = _('Set Part Category')
     form_class = part_forms.SetPartCategoryForm
 
+    permission_required = 'part.change_part'
+
     category = None
     parts = []
     
@@ -290,6 +306,8 @@ class MakePartVariant(AjaxCreateView):
     ajax_form_title = _('Create Variant')
     ajax_template_name = 'part/variant_part.html'
 
+    permission_required = 'part.create_part'
+
     def get_part_template(self):
         return get_object_or_404(Part, id=self.kwargs['pk'])
 
@@ -368,6 +386,8 @@ class PartDuplicate(AjaxCreateView):
     ajax_form_title = _("Duplicate Part")
     ajax_template_name = "part/copy_part.html"
 
+    permission_required = 'part.create_part'
+
     def get_data(self):
         return {
             'success': _('Copied part')
@@ -491,6 +511,8 @@ class PartCreate(AjaxCreateView):
     ajax_form_title = _('Create new part')
     ajax_template_name = 'part/create_part.html'
 
+    permission_required = 'part.create_part'
+
     def get_data(self):
         return {
             'success': _("Created new part"),
@@ -613,6 +635,8 @@ class PartNotes(UpdateView):
     template_name = 'part/notes.html'
     model = Part
 
+    permission_required = 'part.update_part'
+
     fields = ['notes']
 
     def get_success_url(self):
@@ -634,7 +658,7 @@ class PartNotes(UpdateView):
         return ctx
 
 
-class PartDetail(DetailView):
+class PartDetail(PermissionRequiredMixin, DetailView):
     """ Detail view for Part object
     """
 
@@ -642,6 +666,8 @@ class PartDetail(DetailView):
     queryset = Part.objects.all().select_related('category')
     template_name = 'part/detail.html'
 
+    permission_required = 'part.view_part'
+
     # Add in some extra context information based on query params
     def get_context_data(self, **kwargs):
         """ Provide extra context data to template
@@ -706,6 +732,8 @@ class PartQRCode(QRCodeView):
 
     ajax_form_title = _("Part QR Code")
 
+    permission_required = 'part.view_part'
+
     def get_qr_data(self):
         """ Generate QR code data for the Part """
 
@@ -722,8 +750,11 @@ class PartImageUpload(AjaxUpdateView):
     model = Part
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Upload Part Image')
+
     form_class = part_forms.PartImageForm
 
+    permission_required = 'part.update_part'
+
     def get_data(self):
         return {
             'success': _('Updated part image'),
@@ -737,6 +768,8 @@ class PartImageSelect(AjaxUpdateView):
     ajax_template_name = 'part/select_image.html'
     ajax_form_title = _('Select Part Image')
 
+    permission_required = 'part.update_part'
+
     fields = [
         'image',
     ]
@@ -778,6 +811,8 @@ class PartEdit(AjaxUpdateView):
     ajax_form_title = _('Edit Part Properties')
     context_object_name = 'part'
 
+    permission_required = 'part.update_part'
+
     def get_form(self):
         """ Create form for Part editing.
         Overrides default get_form() method to limit the choices
@@ -802,6 +837,8 @@ class BomValidate(AjaxUpdateView):
     context_object_name = 'part'
     form_class = part_forms.BomValidateForm
 
+    permission_required = ('part.update_part')
+
     def get_context(self):
         return {
             'part': self.get_object(),
@@ -832,7 +869,7 @@ class BomValidate(AjaxUpdateView):
         return self.renderJsonResponse(request, form, data, context=self.get_context())
 
 
-class BomUpload(FormView):
+class BomUpload(PermissionRequiredMixin, FormView):
     """ View for uploading a BOM file, and handling BOM data importing.
 
     The BOM upload process is as follows:
@@ -868,6 +905,8 @@ class BomUpload(FormView):
     missing_columns = []
     allowed_parts = []
 
+    permission_required = ('part.update_part', 'part.create_bomitem')
+
     def get_success_url(self):
         part = self.get_object()
         return reverse('upload-bom', kwargs={'pk': part.id})
@@ -1466,6 +1505,8 @@ class BomUpload(FormView):
 class PartExport(AjaxView):
     """ Export a CSV file containing information on multiple parts """
 
+    permission_required = 'part.view_part'
+
     def get_parts(self, request):
         """ Extract part list from the POST parameters.
         Parts can be supplied as:
@@ -1543,6 +1584,8 @@ class BomDownload(AjaxView):
     - File format should be passed as a query param e.g. ?format=csv
     """
 
+    permission_required = ('part.view_part', 'part.view_bomitem')
+
     model = Part
 
     def get(self, request, *args, **kwargs):
@@ -1596,6 +1639,8 @@ class BomExport(AjaxView):
     form_class = part_forms.BomExportForm
     ajax_form_title = _("Export Bill of Materials")
 
+    permission_required = ('part.view_part', 'part.view_bomitem')
+
     def get(self, request, *args, **kwargs):
         return self.renderJsonResponse(request, self.form_class())
 
@@ -1645,6 +1690,8 @@ class PartDelete(AjaxDeleteView):
     ajax_form_title = _('Confirm Part Deletion')
     context_object_name = 'part'
 
+    permission_required = 'part.delete_part'
+
     success_url = '/part/'
 
     def get_data(self):
@@ -1661,6 +1708,8 @@ class PartPricing(AjaxView):
     ajax_form_title = _("Part Pricing")
     form_class = part_forms.PartPriceForm
 
+    permission_required = ('company.view_supplierpricebreak', 'part.view_part')
+
     def get_part(self):
         try:
             return Part.objects.get(id=self.kwargs['pk'])
@@ -1778,6 +1827,8 @@ class PartPricing(AjaxView):
 class PartParameterTemplateCreate(AjaxCreateView):
     """ View for creating a new PartParameterTemplate """
 
+    permission_required = 'part.create_partparametertemplate'
+
     model = PartParameterTemplate
     form_class = part_forms.EditPartParameterTemplateForm
     ajax_form_title = _('Create Part Parameter Template')
@@ -1786,6 +1837,8 @@ class PartParameterTemplateCreate(AjaxCreateView):
 class PartParameterTemplateEdit(AjaxUpdateView):
     """ View for editing a PartParameterTemplate """
 
+    permission_required = 'part.change_partparametertemplate'
+
     model = PartParameterTemplate
     form_class = part_forms.EditPartParameterTemplateForm
     ajax_form_title = _('Edit Part Parameter Template')
@@ -1794,6 +1847,8 @@ class PartParameterTemplateEdit(AjaxUpdateView):
 class PartParameterTemplateDelete(AjaxDeleteView):
     """ View for deleting an existing PartParameterTemplate """
 
+    permission_required = 'part.delete_partparametertemplate'
+
     model = PartParameterTemplate
     ajax_form_title = _("Delete Part Parameter Template")
 
@@ -1801,6 +1856,8 @@ class PartParameterTemplateDelete(AjaxDeleteView):
 class PartParameterCreate(AjaxCreateView):
     """ View for creating a new PartParameter """
 
+    permission_required = 'part.create_partparameter'
+
     model = PartParameter
     form_class = part_forms.EditPartParameterForm
     ajax_form_title = _('Create Part Parameter')
@@ -1851,6 +1908,8 @@ class PartParameterCreate(AjaxCreateView):
 class PartParameterEdit(AjaxUpdateView):
     """ View for editing a PartParameter """
 
+    permission_required = 'part.change_partparameter'
+
     model = PartParameter
     form_class = part_forms.EditPartParameterForm
     ajax_form_title = _('Edit Part Parameter')
@@ -1865,12 +1924,14 @@ class PartParameterEdit(AjaxUpdateView):
 class PartParameterDelete(AjaxDeleteView):
     """ View for deleting a PartParameter """
 
+    permission_required = 'part.delete_partparameter'
+
     model = PartParameter
     ajax_template_name = 'part/param_delete.html'
     ajax_form_title = _('Delete Part Parameter')
     
 
-class CategoryDetail(DetailView):
+class CategoryDetail(PermissionRequiredMixin, DetailView):
     """ Detail view for PartCategory """
 
     model = PartCategory
@@ -1878,6 +1939,8 @@ class CategoryDetail(DetailView):
     queryset = PartCategory.objects.all().prefetch_related('children')
     template_name = 'part/category_partlist.html'
 
+    permission_required = 'part.view_partcategory'
+
     def get_context_data(self, **kwargs):
 
         context = super(CategoryDetail, self).get_context_data(**kwargs).copy()
@@ -1926,6 +1989,8 @@ class CategoryEdit(AjaxUpdateView):
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Edit Part Category')
 
+    permission_required = 'part.change_partcategory'
+
     def get_context_data(self, **kwargs):
         context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
 
@@ -1963,6 +2028,8 @@ class CategoryDelete(AjaxDeleteView):
     context_object_name = 'category'
     success_url = '/part/'
 
+    permission_required = 'part.delete_partcategory'
+
     def get_data(self):
         return {
             'danger': _('Part category was deleted'),
@@ -1977,6 +2044,8 @@ class CategoryCreate(AjaxCreateView):
     ajax_template_name = 'modal_form.html'
     form_class = part_forms.EditCategoryForm
 
+    permission_required = 'part.create_partcategory'
+
     def get_context_data(self, **kwargs):
         """ Add extra context data to template.
 
@@ -2012,12 +2081,14 @@ class CategoryCreate(AjaxCreateView):
         return initials
 
 
-class BomItemDetail(DetailView):
+class BomItemDetail(PermissionRequiredMixin, DetailView):
     """ Detail view for BomItem """
     context_object_name = 'item'
     queryset = BomItem.objects.all()
     template_name = 'part/bom-detail.html'
 
+    permission_required = 'part.view_bomitem'
+
 
 class BomItemCreate(AjaxCreateView):
     """ Create view for making a new BomItem object """
@@ -2026,6 +2097,8 @@ class BomItemCreate(AjaxCreateView):
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Create BOM item')
 
+    permission_required = 'part.create_bomitem'
+
     def get_form(self):
         """ Override get_form() method to reduce Part selection options.
 
@@ -2092,6 +2165,8 @@ class BomItemEdit(AjaxUpdateView):
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Edit BOM item')
 
+    permission_required = 'part.change_bomitem'
+
     def get_form(self):
         """ Override get_form() method to filter part selection options
 
@@ -2140,6 +2215,8 @@ class BomItemDelete(AjaxDeleteView):
     context_object_name = 'item'
     ajax_form_title = _('Confim BOM item deletion')
 
+    permission_required = 'part.delete_bomitem'
+
 
 class PartSalePriceBreakCreate(AjaxCreateView):
     """ View for creating a sale price break for a part """
@@ -2147,6 +2224,8 @@ class PartSalePriceBreakCreate(AjaxCreateView):
     model = PartSellPriceBreak
     form_class = part_forms.EditPartSalePriceBreakForm
     ajax_form_title = _('Add Price Break')
+
+    permission_required = 'part.create_partsellpricebreak'
     
     def get_data(self):
         return {
@@ -2197,6 +2276,8 @@ class PartSalePriceBreakEdit(AjaxUpdateView):
     form_class = part_forms.EditPartSalePriceBreakForm
     ajax_form_title = _('Edit Price Break')
 
+    permission_required = 'part.change_partsellpricebreak'
+
     def get_form(self):
 
         form = super().get_form()
@@ -2211,3 +2292,5 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
     model = PartSellPriceBreak
     ajax_form_title = _("Delete Price Break")
     ajax_template_name = "modal_delete_form.html"
+
+    permission_required = 'part.delete_partsalepricebreak'
diff --git a/InvenTree/templates/403.html b/InvenTree/templates/403.html
new file mode 100644
index 0000000000..372bd9fe27
--- /dev/null
+++ b/InvenTree/templates/403.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+{% block page_title %}
+InvenTree | {% trans "Permission Denied" %}
+{% endblock %}
+
+{% block content %}
+
+<div class='container-fluid'>
+    <h3>{% trans "Permission Denied" %}</h3>
+
+    <div class='alert alert-danger alert-block'>
+        {% trans "You do not have permission to view this page." %}
+    </div>
+</div>
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index a9e4ae92ea..b20d61116d 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -1,7 +1,7 @@
 {% extends "base.html" %}
-
+{% load i18n %}
 {% block page_title %}
-InvenTree | Index
+InvenTree | {% trans "Index" %}
 {% endblock %}
 
 {% block content %}

From 66bdce3d04d1a08be9b87e7016ce6100b8dbf4b2 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Mon, 5 Oct 2020 23:53:24 +1100
Subject: [PATCH 53/77] Hide elements on the PartCategory page, based on
 permissions

---
 InvenTree/part/templates/part/category.html | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 9a6fb9d7e5..44c8cd7df9 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -20,17 +20,23 @@
         {% endif %}
         <p>
             <div class='btn-group action-buttons'>
+                {% if perms.part.create_partcategory %}
                 <button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'>
                     <span class='fas fa-plus-circle icon-green'/>
                 </button>
+                {% endif %}
                 {% if category %}
+                {% if perms.part.change_partcategory %}
                 <button class='btn btn-default' id='cat-edit' title='{% trans "Edit part category" %}'>
                     <span class='fas fa-edit icon-blue'/>
                 </button>
+                {% endif %}
+                {% if perms.part.delete_partcategory %}
                 <button class='btn btn-default' id='cat-delete' title='{% trans "Delete part category" %}'>
                     <span class='fas fa-trash-alt icon-red'/>
                 </button>
                 {% endif %}
+                {% endif %}
             </div>
         </p>
     </div>
@@ -104,11 +110,15 @@
     <div class='button-toolbar container-fluid' style="float: right;">
         <div class='btn-group'>
             <button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>{% trans "Export" %}</button>
+            {% if perms.part.create_part %}
             <button class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>{% trans "New Part" %}</button>
+            {% endif %}
             <div class='btn-group'>
                 <button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
                 <ul class='dropdown-menu'>
+                    {% if perms.part.change_part %}
                     <li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
+                    {% endif %}
                     <li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
                     <li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
                 </ul>
@@ -180,6 +190,7 @@
         location.href = url;
     });
 
+    {% if perms.part.create_part %}
     $("#part-create").click(function() {
         launchModalForm(
             "{% url 'part-create' %}",
@@ -207,6 +218,7 @@
             }
         );
     });
+    {% endif %}
 
     {% if category %}
     $("#cat-edit").click(function () {

From afadd51a1437e15cad23ff51a45bb0cb76c7bb41 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 00:19:44 +1100
Subject: [PATCH 54/77] Fix permissions in views.py

Silly, "add" not "create"
---
 InvenTree/part/views.py | 22 +++++++++++-----------
 1 file changed, 11 insertions(+), 11 deletions(-)

diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 84628eff04..47a77078be 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -78,7 +78,7 @@ class PartAttachmentCreate(AjaxCreateView):
     ajax_form_title = _("Add part attachment")
     ajax_template_name = "modal_form.html"
 
-    permission_required = 'part.create_partattachment'
+    permission_required = 'part.add_partattachment'
 
     def post_save(self):
         """ Record the user that uploaded the attachment """
@@ -166,7 +166,7 @@ class PartTestTemplateCreate(AjaxCreateView):
     form_class = part_forms.EditPartTestTemplateForm
     ajax_form_title = _("Create Test Template")
 
-    permission_required = 'part.create_parttesttemplate'
+    permission_required = 'part.add_parttesttemplate'
     
     def get_initial(self):
 
@@ -306,7 +306,7 @@ class MakePartVariant(AjaxCreateView):
     ajax_form_title = _('Create Variant')
     ajax_template_name = 'part/variant_part.html'
 
-    permission_required = 'part.create_part'
+    permission_required = 'part.add_part'
 
     def get_part_template(self):
         return get_object_or_404(Part, id=self.kwargs['pk'])
@@ -386,7 +386,7 @@ class PartDuplicate(AjaxCreateView):
     ajax_form_title = _("Duplicate Part")
     ajax_template_name = "part/copy_part.html"
 
-    permission_required = 'part.create_part'
+    permission_required = 'part.add_part'
 
     def get_data(self):
         return {
@@ -511,7 +511,7 @@ class PartCreate(AjaxCreateView):
     ajax_form_title = _('Create new part')
     ajax_template_name = 'part/create_part.html'
 
-    permission_required = 'part.create_part'
+    permission_required = 'part.add_part'
 
     def get_data(self):
         return {
@@ -905,7 +905,7 @@ class BomUpload(PermissionRequiredMixin, FormView):
     missing_columns = []
     allowed_parts = []
 
-    permission_required = ('part.update_part', 'part.create_bomitem')
+    permission_required = ('part.update_part', 'part.add_bomitem')
 
     def get_success_url(self):
         part = self.get_object()
@@ -1827,7 +1827,7 @@ class PartPricing(AjaxView):
 class PartParameterTemplateCreate(AjaxCreateView):
     """ View for creating a new PartParameterTemplate """
 
-    permission_required = 'part.create_partparametertemplate'
+    permission_required = 'part.add_partparametertemplate'
 
     model = PartParameterTemplate
     form_class = part_forms.EditPartParameterTemplateForm
@@ -1856,7 +1856,7 @@ class PartParameterTemplateDelete(AjaxDeleteView):
 class PartParameterCreate(AjaxCreateView):
     """ View for creating a new PartParameter """
 
-    permission_required = 'part.create_partparameter'
+    permission_required = 'part.add_partparameter'
 
     model = PartParameter
     form_class = part_forms.EditPartParameterForm
@@ -2044,7 +2044,7 @@ class CategoryCreate(AjaxCreateView):
     ajax_template_name = 'modal_form.html'
     form_class = part_forms.EditCategoryForm
 
-    permission_required = 'part.create_partcategory'
+    permission_required = 'part.add_partcategory'
 
     def get_context_data(self, **kwargs):
         """ Add extra context data to template.
@@ -2097,7 +2097,7 @@ class BomItemCreate(AjaxCreateView):
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Create BOM item')
 
-    permission_required = 'part.create_bomitem'
+    permission_required = 'part.add_bomitem'
 
     def get_form(self):
         """ Override get_form() method to reduce Part selection options.
@@ -2225,7 +2225,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
     form_class = part_forms.EditPartSalePriceBreakForm
     ajax_form_title = _('Add Price Break')
 
-    permission_required = 'part.create_partsellpricebreak'
+    permission_required = 'part.add_partsellpricebreak'
     
     def get_data(self):
         return {

From ba4c829b10137ca1a37cf7d1dde0b73ce8d0cb57 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 00:20:45 +1100
Subject: [PATCH 55/77] Add permission requirements in various part templates

---
 InvenTree/part/templates/part/bom.html       |  2 ++
 InvenTree/part/templates/part/build.html     |  7 +++++--
 InvenTree/part/templates/part/category.html  |  6 +++---
 InvenTree/part/templates/part/notes.html     |  2 ++
 InvenTree/part/templates/part/params.html    |  8 +++++++
 InvenTree/part/templates/part/part_base.html | 22 ++++++++++++++++----
 InvenTree/part/templates/part/tabs.html      |  9 +++++---
 InvenTree/templates/slide.html               |  2 +-
 InvenTree/templates/stock_table.html         |  8 +++++++
 9 files changed, 53 insertions(+), 13 deletions(-)

diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html
index 2653a42575..610e60847a 100644
--- a/InvenTree/part/templates/part/bom.html
+++ b/InvenTree/part/templates/part/bom.html
@@ -39,10 +39,12 @@
     <button class='btn btn-default action-button' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'><span class='fas fa-plus-circle'></span></button>
     <button class='btn btn-default action-button' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'><span class='fas fa-check-circle'></span></button>
     {% elif part.active %}
+    {% if perms.part.change_part %}
     <button class='btn btn-default action-button' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'><span class='fas fa-edit'></span></button>
     {% if part.is_bom_valid == False %}
     <button class='btn btn-default action-button' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'><span class='fas fa-clipboard-check'></span></button>
     {% endif %}
+    {% endif %}
     <button title='{% trans "Export Bill of Materials" %}' class='btn btn-default action-button' id='download-bom' type='button'><span class='fas fa-file-download'></span></button>
     {% endif %}
 </div>
diff --git a/InvenTree/part/templates/part/build.html b/InvenTree/part/templates/part/build.html
index 442693ddeb..ad51d33ab2 100644
--- a/InvenTree/part/templates/part/build.html
+++ b/InvenTree/part/templates/part/build.html
@@ -1,15 +1,18 @@
 {% extends "part/part_base.html" %}
 {% load static %}
+{% load i18n %}
 {% block details %}
 
 {% include 'part/tabs.html' with tab='build' %}
 
-<h3>Part Builds</h3>
+<h3>{% trans "Part Builds" %}</h3>
 
 <div id='button-toolbar'>
     <div class='button-toolbar container-flui' style='float: right';>
         {% if part.active %}
-        <button class="btn btn-success" id='start-build'>Start New Build</button>
+        {% if perms.build.add_build %}
+        <button class="btn btn-success" id='start-build'>{% trans "Start New Build" %}</button>
+        {% endif %}
         {% endif %}
         <div class='filter-list' id='filter-list-build'>
             <!-- Empty div for filters -->
diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 44c8cd7df9..25417a1d9b 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -20,7 +20,7 @@
         {% endif %}
         <p>
             <div class='btn-group action-buttons'>
-                {% if perms.part.create_partcategory %}
+                {% if perms.part.add_partcategory %}
                 <button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'>
                     <span class='fas fa-plus-circle icon-green'/>
                 </button>
@@ -110,7 +110,7 @@
     <div class='button-toolbar container-fluid' style="float: right;">
         <div class='btn-group'>
             <button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>{% trans "Export" %}</button>
-            {% if perms.part.create_part %}
+            {% if perms.part.add_part %}
             <button class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>{% trans "New Part" %}</button>
             {% endif %}
             <div class='btn-group'>
@@ -190,7 +190,7 @@
         location.href = url;
     });
 
-    {% if perms.part.create_part %}
+    {% if perms.part.add_part %}
     $("#part-create").click(function() {
         launchModalForm(
             "{% url 'part-create' %}",
diff --git a/InvenTree/part/templates/part/notes.html b/InvenTree/part/templates/part/notes.html
index afa215600d..68711c881e 100644
--- a/InvenTree/part/templates/part/notes.html
+++ b/InvenTree/part/templates/part/notes.html
@@ -29,7 +29,9 @@
         <h4>{% trans "Part Notes" %}</h4>
     </div>
     <div class='col-sm-6'>
+        {% if perms.part.change_part %}
         <button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
+        {% endif %}
     </div>
 </div>
 <hr>
diff --git a/InvenTree/part/templates/part/params.html b/InvenTree/part/templates/part/params.html
index 8ad4f61252..9984830242 100644
--- a/InvenTree/part/templates/part/params.html
+++ b/InvenTree/part/templates/part/params.html
@@ -10,7 +10,9 @@
 
 <div id='button-toolbar'>
     <div class='button-toolbar container-fluid' style='float: right;'>
+        {% if perms.part.add_partparameter %}
         <button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>{% trans "New Parameter" %}</button>
+        {% endif %}
     </div>
 </div>
 
@@ -30,8 +32,12 @@
             <td>
                 {{ param.template.units }}
                 <div class='btn-group' style='float: right;'>
+                    {% if perms.part.change_partparameter %}
                     <button title='{% trans "Edit" %}' class='btn btn-default btn-glyph param-edit' url="{% url 'part-param-edit' param.id %}" type='button'><span class='fas fa-edit'/></button>
+                    {% endif %}
+                    {% if perms.part.delete_partparameter %}
                     <button title='{% trans "Delete" %}' class='btn btn-default btn-glyph param-delete' url="{% url 'part-param-delete' param.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
+                    {% endif %}
                 </div>
             </td>
         </tr>
@@ -48,6 +54,7 @@
     $('#param-table').inventreeTable({
     });
 
+    {% if perms.part.add_partparameter %}
     $('#param-create').click(function() {
         launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", {
             reload: true,
@@ -59,6 +66,7 @@
             }],
         });
     });
+    {% endif %}
 
     $('.param-edit').click(function() {
         var button = $(this);
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 1de4976740..6103cb5369 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -56,26 +56,36 @@
             <button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
                 <span id='part-price-icon' class='fas fa-dollar-sign'/>
             </button>
-            <button type='button' class='btn btn-default' id='part-count' title='Count part stock'>
+            {% if perms.stock.change_stockitem %}
+            <button type='button' class='btn btn-default' id='part-count' title='{% trans "Count part stock" %}'>
                 <span class='fas fa-clipboard-list'/>
             </button>
+            {% endif %}
             {% if part.purchaseable %}
-            <button type='button' class='btn btn-default' id='part-order' title='Order part'>
+            {% if perms.order.add_purchaseorder %}
+            <button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
                 <span id='part-order-icon' class='fas fa-shopping-cart'/>
             </button>
             {% endif %}
             {% endif %}
+            {% endif %}
             <!-- Part actions -->
+            {% if perms.part.add_part or perms.part.change_part or perms.part.delete_part %}
             <div class='btn-group'>
                 <button id='part-actions' title='{% trans "Part actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <span class='fas fa-shapes'></span> <span class='caret'></span></button>
                 <ul class='dropdown-menu'>
+                    {% if perms.part.add_part %}
                     <li><a href='#' id='part-duplicate'><span class='fas fa-copy'></span> {% trans "Duplicate part" %}</a></li>
+                    {% endif %}
+                    {% if perms.part.change_part %}
                     <li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li>
-                    {% if not part.active %}
+                    {% endif %}
+                    {% if not part.active and perms.part.delete_part %}
                     <li><a href='#' id='part-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete part" %}</a></li>
                     {% endif %}
                 </ul>
             </div>
+            {% endif %}
         </div>
         <table class='table table-condensed'>
             <col width='25'>
@@ -274,6 +284,7 @@
                         });
     });
 
+    {% if perms.part.change_part %}
     $("#part-edit").click(function() {
         launchModalForm(
             "{% url 'part-edit' part.id %}",
@@ -282,6 +293,7 @@
             }
         );
     });
+    {% endif %}
 
     $("#part-order").click(function() {
         launchModalForm("{% url 'order-parts' %}", {
@@ -292,6 +304,7 @@
         });
     });
 
+    {% if perms.part.add_part %}
     $("#part-duplicate").click(function() {
         launchModalForm(
             "{% url 'part-duplicate' part.id %}",
@@ -300,8 +313,9 @@
             }
         );
     });
+    {% endif %}
 
-    {% if not part.active %}
+    {% if not part.active and perms.part.delete_part %}
     $("#part-delete").click(function() {
         launchModalForm(
             "{% url 'part-delete' part.id %}",
diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html
index 1eab299ed5..02d8672979 100644
--- a/InvenTree/part/templates/part/tabs.html
+++ b/InvenTree/part/templates/part/tabs.html
@@ -26,14 +26,17 @@
     {% if part.assembly %}
     <li{% ifequal tab 'bom' %} class="active"{% endifequal %}>
         <a href="{% url 'part-bom' part.id %}">{% trans "BOM" %}<span class="badge{% if part.is_bom_valid == False %} badge-alert{% endif %}">{{ part.bom_count }}</span></a></li>
+    {% if perms.build.view_build %}
     <li{% ifequal tab 'build' %} class="active"{% endifequal %}>
-        <a href="{% url 'part-build' part.id %}">{% trans "Build Orders" %}<span class='badge'>{{ part.builds.count }}</span></a></li>
+        <a href="{% url 'part-build' part.id %}">{% trans "Build Orders" %}<span class='badge'>{{ part.builds.count }}</span></a>
+    </li>
+    {% endif %}
     {% endif %}
     {% if part.component or part.used_in_count > 0 %}
     <li{% ifequal tab 'used' %} class="active"{% endifequal %}>
         <a href="{% url 'part-used-in' part.id %}">{% trans "Used In" %} {% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li>
     {% endif %}
-    {% if part.purchaseable %}
+    {% if part.purchaseable and perms.order.view_purchaseorder %}
     {% if part.is_template == False %}
     <li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}>
         <a href="{% url 'part-suppliers' part.id %}">{% trans "Suppliers" %}
@@ -45,7 +48,7 @@
         <a href="{% url 'part-orders' part.id %}">{% trans "Purchase Orders" %} <span class='badge'>{{ part.purchase_orders|length }}</span></a>
     </li>
     {% endif %}
-    {% if part.salable %}
+    {% if part.salable and perms.order.view_salesorder %}
     <li {% if tab == 'sales-prices' %}class='active'{% endif %}>
         <a href="{% url 'part-sale-prices' part.id %}">{% trans "Sale Price" %}</a>
     </li>
diff --git a/InvenTree/templates/slide.html b/InvenTree/templates/slide.html
index e2fe2e932f..45535786c9 100644
--- a/InvenTree/templates/slide.html
+++ b/InvenTree/templates/slide.html
@@ -1,3 +1,3 @@
 <div>
-    <input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off">
+    <input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled or not perms.part.change_part %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off">
 </div>
\ No newline at end of file
diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html
index c41d2181c9..be26b1a976 100644
--- a/InvenTree/templates/stock_table.html
+++ b/InvenTree/templates/stock_table.html
@@ -6,19 +6,27 @@
             <button class='btn btn-default' id='stock-export' title='{% trans "Export Stock Information" %}'>{% trans "Export" %}</button>
             {% if read_only %}
             {% else %}
+            {% if perms.stock.add_stockitem %}
             <button class="btn btn-success" id='item-create'>{% trans "New Stock Item" %}</button>
+            {% endif %}
+            {% if perms.stock.change_stockitem or perms.stock.delete_stockitem %}
             <div class="btn-group">
                 <button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
                 <ul class="dropdown-menu">
+                    {% if perms.stock.change_stockitem %}
                     <li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'>{% trans "Add stock" %}</a></li>
                     <li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'>{% trans "Remove stock" %}</a></li>
                     <li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'>{% trans "Count stock" %}</a></li>
                     <li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'>{% trans "Move stock" %}</a></li>
                     <li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'>{% trans "Order stock" %}</a></li>
+                    {% endif %}
+                    {% if perms.stock.delete_stockitem %}
                     <li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'>{% trans "Delete Stock" %}</a></li>
+                    {% endif %}
                 </ul>
             </div>
             {% endif %}
+            {% endif %}
         </div>
         <div class='filter-list' id='filter-list-stock'>
             <!-- An empty div in which the filter list will be constructed -->

From 8ee16d6f98600e4a618254f1dc0a79ab7113c2ed Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 00:20:57 +1100
Subject: [PATCH 56/77] update translation files

---
 InvenTree/locale/de/LC_MESSAGES/django.po | 405 ++++++++++++++--------
 InvenTree/locale/en/LC_MESSAGES/django.po | 397 ++++++++++++---------
 InvenTree/locale/es/LC_MESSAGES/django.po | 397 ++++++++++++---------
 3 files changed, 731 insertions(+), 468 deletions(-)

diff --git a/InvenTree/locale/de/LC_MESSAGES/django.po b/InvenTree/locale/de/LC_MESSAGES/django.po
index 493d4a8b06..7632fadc4c 100644
--- a/InvenTree/locale/de/LC_MESSAGES/django.po
+++ b/InvenTree/locale/de/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-10-04 14:02+0000\n"
+"POT-Creation-Date: 2020-10-05 13:20+0000\n"
 "PO-Revision-Date: 2020-05-03 11:32+0200\n"
 "Last-Translator: Christian Schlüter <chschlue@gmail.com>\n"
 "Language-Team: C <kde-i18n-doc@kde.org>\n"
@@ -96,7 +96,7 @@ msgstr "Datei-Kommentar"
 msgid "User"
 msgstr "Benutzer"
 
-#: InvenTree/models.py:106 part/templates/part/params.html:20
+#: InvenTree/models.py:106 part/templates/part/params.html:22
 #: templates/js/part.html:81
 msgid "Name"
 msgstr "Name"
@@ -107,19 +107,19 @@ msgstr "Name"
 msgid "Description (optional)"
 msgstr "Firmenbeschreibung"
 
-#: InvenTree/settings.py:342
+#: InvenTree/settings.py:343
 msgid "English"
 msgstr "Englisch"
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:344
 msgid "German"
 msgstr "Deutsch"
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:345
 msgid "French"
 msgstr "Französisch"
 
-#: InvenTree/settings.py:345
+#: InvenTree/settings.py:346
 msgid "Polish"
 msgstr "Polnisch"
 
@@ -345,7 +345,7 @@ msgstr "Bau-Anzahl"
 msgid "Number of parts to build"
 msgstr "Anzahl der zu bauenden Teile"
 
-#: build/models.py:128 part/templates/part/part_base.html:145
+#: build/models.py:128 part/templates/part/part_base.html:155
 msgid "Build Status"
 msgstr "Bau-Status"
 
@@ -364,7 +364,7 @@ msgstr "Chargennummer für diese Bau-Ausgabe"
 #: build/models.py:155 build/templates/build/detail.html:55
 #: company/templates/company/supplier_part_base.html:60
 #: company/templates/company/supplier_part_detail.html:24
-#: part/templates/part/detail.html:80 part/templates/part/part_base.html:92
+#: part/templates/part/detail.html:80 part/templates/part/part_base.html:102
 #: stock/models.py:381 stock/templates/stock/item_base.html:244
 msgid "External Link"
 msgstr "Externer Link"
@@ -376,7 +376,7 @@ msgstr "Link zu einer externen URL"
 #: build/models.py:160 build/templates/build/tabs.html:14 company/models.py:310
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:15
 #: order/templates/order/purchase_order_detail.html:202
-#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:67
+#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:70
 #: stock/forms.py:306 stock/forms.py:338 stock/forms.py:366 stock/models.py:453
 #: stock/models.py:1404 stock/templates/stock/tabs.html:26
 #: templates/js/barcode.html:391 templates/js/bom.html:223
@@ -425,7 +425,7 @@ msgstr "Lagerobjekt-Anzahl dem Bau zuweisen"
 
 #: build/templates/build/allocate.html:17
 #: company/templates/company/detail_part.html:18 order/views.py:779
-#: part/templates/part/category.html:112
+#: part/templates/part/category.html:122
 msgid "Order Parts"
 msgstr "Teile bestellen"
 
@@ -441,7 +441,7 @@ msgstr "Automatisches Zuweisen"
 msgid "Unallocate"
 msgstr "Zuweisung aufheben"
 
-#: build/templates/build/allocate.html:87 templates/stock_table.html:9
+#: build/templates/build/allocate.html:87 templates/stock_table.html:10
 msgid "New Stock Item"
 msgstr "Neues Lagerobjekt"
 
@@ -574,7 +574,7 @@ msgstr "Lagerobjekt wurde zugewiesen"
 #: build/templates/build/build_base.html:34
 #: build/templates/build/complete.html:6
 #: stock/templates/stock/item_base.html:223 templates/js/build.html:39
-#: templates/navbar.html:20
+#: templates/navbar.html:25
 msgid "Build"
 msgstr "Bau"
 
@@ -717,7 +717,7 @@ msgstr "Fertig"
 
 #: build/templates/build/index.html:6 build/templates/build/index.html:14
 #: order/templates/order/so_builds.html:11 order/templates/order/so_tabs.html:9
-#: part/templates/part/tabs.html:30
+#: part/templates/part/tabs.html:31 users/models.py:30
 msgid "Build Orders"
 msgstr "Bauaufträge"
 
@@ -739,7 +739,7 @@ msgstr "Speichern"
 #: build/templates/build/notes.html:33 company/templates/company/notes.html:30
 #: order/templates/order/order_notes.html:32
 #: order/templates/order/sales_order_notes.html:37
-#: part/templates/part/notes.html:32 stock/templates/stock/item_notes.html:33
+#: part/templates/part/notes.html:33 stock/templates/stock/item_notes.html:33
 msgid "Edit notes"
 msgstr "Bermerkungen bearbeiten"
 
@@ -1085,13 +1085,13 @@ msgid "New Supplier Part"
 msgstr "Neues Zulieferer-Teil"
 
 #: company/templates/company/detail_part.html:15
-#: part/templates/part/category.html:109 part/templates/part/supplier.html:15
-#: templates/stock_table.html:11
+#: part/templates/part/category.html:117 part/templates/part/supplier.html:15
+#: templates/stock_table.html:14
 msgid "Options"
 msgstr "Optionen"
 
 #: company/templates/company/detail_part.html:18
-#: part/templates/part/category.html:112
+#: part/templates/part/category.html:122
 #, fuzzy
 #| msgid "Order part"
 msgid "Order parts"
@@ -1108,7 +1108,7 @@ msgid "Delete Parts"
 msgstr "Teile löschen"
 
 #: company/templates/company/detail_part.html:43
-#: part/templates/part/category.html:107 templates/js/stock.html:791
+#: part/templates/part/category.html:114 templates/js/stock.html:791
 msgid "New Part"
 msgstr "Neues Teil"
 
@@ -1140,7 +1140,7 @@ msgstr "Zuliefererbestand"
 
 #: company/templates/company/detail_stock.html:35
 #: company/templates/company/supplier_part_stock.html:33
-#: part/templates/part/category.html:106 part/templates/part/category.html:113
+#: part/templates/part/category.html:112 part/templates/part/category.html:123
 #: part/templates/part/stock.html:51 templates/stock_table.html:6
 msgid "Export"
 msgstr "Exportieren"
@@ -1163,8 +1163,8 @@ msgstr ""
 #: company/templates/company/tabs.html:17
 #: order/templates/order/purchase_orders.html:7
 #: order/templates/order/purchase_orders.html:12
-#: part/templates/part/orders.html:9 part/templates/part/tabs.html:45
-#: templates/navbar.html:26
+#: part/templates/part/orders.html:9 part/templates/part/tabs.html:48
+#: templates/navbar.html:33 users/models.py:31
 msgid "Purchase Orders"
 msgstr "Bestellungen"
 
@@ -1182,8 +1182,8 @@ msgstr "Neue Bestellung"
 #: company/templates/company/tabs.html:22
 #: order/templates/order/sales_orders.html:7
 #: order/templates/order/sales_orders.html:12
-#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:53
-#: templates/navbar.html:33
+#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:56
+#: templates/navbar.html:42 users/models.py:32
 msgid "Sales Orders"
 msgstr "Bestellungen"
 
@@ -1204,7 +1204,7 @@ msgid "Supplier Part"
 msgstr "Zulieferer-Teil"
 
 #: company/templates/company/supplier_part_base.html:23
-#: part/templates/part/orders.html:14
+#: part/templates/part/orders.html:14 part/templates/part/part_base.html:66
 msgid "Order part"
 msgstr "Teil bestellen"
 
@@ -1251,7 +1251,7 @@ msgid "Pricing Information"
 msgstr "Preisinformationen ansehen"
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2149
+#: part/templates/part/sale_prices.html:13 part/views.py:2226
 msgid "Add Price Break"
 msgstr "Preisstaffel hinzufügen"
 
@@ -1293,7 +1293,7 @@ msgstr "Bepreisung"
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
 #: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
 #: templates/js/part.html:124 templates/js/part.html:372
-#: templates/js/stock.html:452 templates/navbar.html:19
+#: templates/js/stock.html:452 templates/navbar.html:22 users/models.py:29
 msgid "Stock"
 msgstr "Lagerbestand"
 
@@ -1303,22 +1303,22 @@ msgstr "Bestellungen"
 
 #: company/templates/company/tabs.html:9
 #: order/templates/order/receive_parts.html:14 part/models.py:294
-#: part/templates/part/cat_link.html:7 part/templates/part/category.html:88
-#: part/templates/part/category_tabs.html:6 templates/navbar.html:18
-#: templates/stats.html:8 templates/stats.html:17
+#: part/templates/part/cat_link.html:7 part/templates/part/category.html:94
+#: part/templates/part/category_tabs.html:6 templates/navbar.html:19
+#: templates/stats.html:8 templates/stats.html:17 users/models.py:28
 msgid "Parts"
 msgstr "Teile"
 
-#: company/views.py:50 part/templates/part/tabs.html:39
-#: templates/navbar.html:24
+#: company/views.py:50 part/templates/part/tabs.html:42
+#: templates/navbar.html:31
 msgid "Suppliers"
 msgstr "Zulieferer"
 
-#: company/views.py:57 templates/navbar.html:25
+#: company/views.py:57 templates/navbar.html:32
 msgid "Manufacturers"
 msgstr "Hersteller"
 
-#: company/views.py:64 templates/navbar.html:32
+#: company/views.py:64 templates/navbar.html:41
 msgid "Customers"
 msgstr "Kunden"
 
@@ -1382,17 +1382,17 @@ msgstr "Neues Zuliefererteil anlegen"
 msgid "Delete Supplier Part"
 msgstr "Zuliefererteil entfernen"
 
-#: company/views.py:404 part/views.py:2153
+#: company/views.py:404 part/views.py:2232
 #, fuzzy
 #| msgid "Add Price Break"
 msgid "Added new price break"
 msgstr "Preisstaffel hinzufügen"
 
-#: company/views.py:441 part/views.py:2198
+#: company/views.py:441 part/views.py:2277
 msgid "Edit Price Break"
 msgstr "Preisstaffel bearbeiten"
 
-#: company/views.py:456 part/views.py:2212
+#: company/views.py:456 part/views.py:2293
 msgid "Delete Price Break"
 msgstr "Preisstaffel löschen"
 
@@ -1497,7 +1497,7 @@ msgstr ""
 msgid "Date order was completed"
 msgstr "Bestellung als vollständig markieren"
 
-#: order/models.py:185 order/models.py:259 part/views.py:1304
+#: order/models.py:185 order/models.py:259 part/views.py:1343
 #: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr "Anzahl muss größer Null sein"
@@ -1667,7 +1667,7 @@ msgid "Purchase Order Attachments"
 msgstr "Bestellanhänge"
 
 #: order/templates/order/po_tabs.html:8 order/templates/order/so_tabs.html:16
-#: part/templates/part/tabs.html:64 stock/templates/stock/tabs.html:32
+#: part/templates/part/tabs.html:67 stock/templates/stock/tabs.html:32
 msgid "Attachments"
 msgstr "Anhänge"
 
@@ -1683,7 +1683,7 @@ msgstr "Bestellpositionen"
 
 #: order/templates/order/purchase_order_detail.html:38
 #: order/templates/order/purchase_order_detail.html:118
-#: part/templates/part/category.html:161 part/templates/part/category.html:202
+#: part/templates/part/category.html:171 part/templates/part/category.html:213
 #: templates/js/stock.html:803
 msgid "New Location"
 msgstr "Neuer Standort"
@@ -1725,7 +1725,7 @@ msgid "Select parts to receive against this order"
 msgstr ""
 
 #: order/templates/order/receive_parts.html:21
-#: part/templates/part/part_base.html:135 templates/js/part.html:388
+#: part/templates/part/part_base.html:145 templates/js/part.html:388
 msgid "On Order"
 msgstr "bestellt"
 
@@ -1823,7 +1823,7 @@ msgstr "Bestellungspositionen"
 msgid "Add Purchase Order Attachment"
 msgstr "Bestellanhang hinzufügen"
 
-#: order/views.py:102 order/views.py:149 part/views.py:86 stock/views.py:167
+#: order/views.py:102 order/views.py:149 part/views.py:90 stock/views.py:167
 msgid "Added attachment"
 msgstr "Anhang hinzugefügt"
 
@@ -1963,12 +1963,12 @@ msgstr "Zuordnung bearbeiten"
 msgid "Remove allocation"
 msgstr "Zuordnung entfernen"
 
-#: part/bom.py:138 part/templates/part/category.html:55
+#: part/bom.py:138 part/templates/part/category.html:61
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr "Standard-Lagerort"
 
-#: part/bom.py:139 part/templates/part/part_base.html:108
+#: part/bom.py:139 part/templates/part/part_base.html:118
 msgid "Available Stock"
 msgstr "Verfügbarer Lagerbestand"
 
@@ -2100,7 +2100,7 @@ msgid "Part Category"
 msgstr "Teilkategorie"
 
 #: part/models.py:76 part/templates/part/category.html:18
-#: part/templates/part/category.html:83 templates/stats.html:12
+#: part/templates/part/category.html:89 templates/stats.html:12
 msgid "Part Categories"
 msgstr "Teile-Kategorien"
 
@@ -2339,7 +2339,7 @@ msgstr "Notizen zum Stücklisten-Objekt"
 msgid "BOM line checksum"
 msgstr "Prüfsumme der Stückliste"
 
-#: part/models.py:1612 part/views.py:1310 part/views.py:1362
+#: part/models.py:1612 part/views.py:1349 part/views.py:1401
 #: stock/models.py:231
 #, fuzzy
 #| msgid "Overage must be an integer value or a percentage"
@@ -2402,25 +2402,25 @@ msgstr "Neue Stücklistenposition"
 msgid "Finish Editing"
 msgstr "Bearbeitung beenden"
 
-#: part/templates/part/bom.html:42
+#: part/templates/part/bom.html:43
 msgid "Edit BOM"
 msgstr "Stückliste bearbeiten"
 
-#: part/templates/part/bom.html:44
+#: part/templates/part/bom.html:45
 msgid "Validate Bill of Materials"
 msgstr "Stückliste validieren"
 
-#: part/templates/part/bom.html:46 part/views.py:1597
+#: part/templates/part/bom.html:48 part/views.py:1640
 msgid "Export Bill of Materials"
 msgstr "Stückliste exportieren"
 
-#: part/templates/part/bom.html:101
+#: part/templates/part/bom.html:103
 #, fuzzy
 #| msgid "Remove selected BOM items"
 msgid "Delete selected BOM items?"
 msgstr "Ausgewählte Stücklistenpositionen entfernen"
 
-#: part/templates/part/bom.html:102
+#: part/templates/part/bom.html:104
 #, fuzzy
 #| msgid "Remove selected BOM items"
 msgid "All selected BOM items will be deleted"
@@ -2510,101 +2510,113 @@ msgstr "Neues Bild hochladen"
 msgid "Each part must already exist in the database"
 msgstr ""
 
+#: part/templates/part/build.html:8
+#, fuzzy
+#| msgid "Parent Build"
+msgid "Part Builds"
+msgstr "Eltern-Bau"
+
+#: part/templates/part/build.html:14
+#, fuzzy
+#| msgid "Start new Build"
+msgid "Start New Build"
+msgstr "Neuen Bau beginnen"
+
 #: part/templates/part/category.html:19
 msgid "All parts"
 msgstr "Alle Teile"
 
-#: part/templates/part/category.html:23 part/views.py:1976
+#: part/templates/part/category.html:24 part/views.py:2043
 msgid "Create new part category"
 msgstr "Teilkategorie anlegen"
 
-#: part/templates/part/category.html:27
+#: part/templates/part/category.html:30
 #, fuzzy
 #| msgid "Edit Part Category"
 msgid "Edit part category"
 msgstr "Teilkategorie bearbeiten"
 
-#: part/templates/part/category.html:30
+#: part/templates/part/category.html:35
 #, fuzzy
 #| msgid "Select part category"
 msgid "Delete part category"
 msgstr "Teilekategorie wählen"
 
-#: part/templates/part/category.html:39 part/templates/part/category.html:78
+#: part/templates/part/category.html:45 part/templates/part/category.html:84
 msgid "Category Details"
 msgstr "Kategorie-Details"
 
-#: part/templates/part/category.html:44
+#: part/templates/part/category.html:50
 msgid "Category Path"
 msgstr "Pfad zur Kategorie"
 
-#: part/templates/part/category.html:49
+#: part/templates/part/category.html:55
 msgid "Category Description"
 msgstr "Kategorie-Beschreibung"
 
-#: part/templates/part/category.html:62 part/templates/part/detail.html:64
+#: part/templates/part/category.html:68 part/templates/part/detail.html:64
 msgid "Keywords"
 msgstr "Schlüsselwörter"
 
-#: part/templates/part/category.html:68
+#: part/templates/part/category.html:74
 msgid "Subcategories"
 msgstr "Unter-Kategorien"
 
-#: part/templates/part/category.html:73
+#: part/templates/part/category.html:79
 msgid "Parts (Including subcategories)"
 msgstr "Teile (inklusive Unter-Kategorien)"
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:112
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:107 part/views.py:491
+#: part/templates/part/category.html:114 part/views.py:511
 msgid "Create new part"
 msgstr "Neues Teil anlegen"
 
-#: part/templates/part/category.html:111
+#: part/templates/part/category.html:120
 #, fuzzy
 #| msgid "Part category"
 msgid "Set category"
 msgstr "Teile-Kategorie"
 
-#: part/templates/part/category.html:111
+#: part/templates/part/category.html:120
 #, fuzzy
 #| msgid "Set Part Category"
 msgid "Set Category"
 msgstr "Teilkategorie auswählen"
 
-#: part/templates/part/category.html:113
+#: part/templates/part/category.html:123
 #, fuzzy
 #| msgid "Export"
 msgid "Export Data"
 msgstr "Exportieren"
 
-#: part/templates/part/category.html:162
+#: part/templates/part/category.html:172
 #, fuzzy
 #| msgid "Create New Location"
 msgid "Create new location"
 msgstr "Neuen Standort anlegen"
 
-#: part/templates/part/category.html:167 part/templates/part/category.html:196
+#: part/templates/part/category.html:177 part/templates/part/category.html:207
 #, fuzzy
 #| msgid "Category"
 msgid "New Category"
 msgstr "Kategorie"
 
-#: part/templates/part/category.html:168
+#: part/templates/part/category.html:178
 #, fuzzy
 #| msgid "Create new part category"
 msgid "Create new category"
 msgstr "Teilkategorie anlegen"
 
-#: part/templates/part/category.html:197
+#: part/templates/part/category.html:208
 #, fuzzy
 #| msgid "Create new part category"
 msgid "Create new Part Category"
 msgstr "Teilkategorie anlegen"
 
-#: part/templates/part/category.html:203 stock/views.py:1314
+#: part/templates/part/category.html:214 stock/views.py:1314
 msgid "Create new Stock Location"
 msgstr "Neuen Lager-Standort erstellen"
 
@@ -2618,7 +2630,7 @@ msgstr "Parameter Wert"
 msgid "Part Details"
 msgstr "Teile-Details"
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:85
+#: part/templates/part/detail.html:25 part/templates/part/part_base.html:95
 #: templates/js/part.html:112
 msgid "IPN"
 msgstr "IPN (Interne Produktnummer)"
@@ -2652,7 +2664,7 @@ msgstr "Kategorie"
 msgid "Default Supplier"
 msgstr "Standard-Zulieferer"
 
-#: part/templates/part/detail.html:102 part/templates/part/params.html:22
+#: part/templates/part/detail.html:102 part/templates/part/params.html:24
 msgid "Units"
 msgstr "Einheiten"
 
@@ -2785,24 +2797,25 @@ msgstr "Teil bestellen"
 msgid "Part Parameters"
 msgstr "Teilparameter"
 
-#: part/templates/part/params.html:13
+#: part/templates/part/params.html:14
 msgid "Add new parameter"
 msgstr "Parameter hinzufügen"
 
-#: part/templates/part/params.html:13 templates/InvenTree/settings/part.html:12
+#: part/templates/part/params.html:14 templates/InvenTree/settings/part.html:12
 msgid "New Parameter"
 msgstr "Neuer Parameter"
 
-#: part/templates/part/params.html:21 stock/models.py:1391
+#: part/templates/part/params.html:23 stock/models.py:1391
 #: templates/js/stock.html:112
 msgid "Value"
 msgstr "Wert"
 
-#: part/templates/part/params.html:33
+#: part/templates/part/params.html:36
 msgid "Edit"
 msgstr "Bearbeiten"
 
-#: part/templates/part/params.html:34 part/templates/part/supplier.html:17
+#: part/templates/part/params.html:39 part/templates/part/supplier.html:17
+#: users/models.py:141
 msgid "Delete"
 msgstr "Löschen"
 
@@ -2859,47 +2872,53 @@ msgstr ""
 msgid "Show pricing information"
 msgstr "Kosteninformationen ansehen"
 
-#: part/templates/part/part_base.html:70
+#: part/templates/part/part_base.html:60
+#, fuzzy
+#| msgid "Count stock"
+msgid "Count part stock"
+msgstr "Bestand zählen"
+
+#: part/templates/part/part_base.html:75
 #, fuzzy
 #| msgid "Source Location"
 msgid "Part actions"
 msgstr "Quell-Standort"
 
-#: part/templates/part/part_base.html:72
+#: part/templates/part/part_base.html:78
 #, fuzzy
 #| msgid "Duplicate Part"
 msgid "Duplicate part"
 msgstr "Teil duplizieren"
 
-#: part/templates/part/part_base.html:73
+#: part/templates/part/part_base.html:81
 #, fuzzy
 #| msgid "Edit Template"
 msgid "Edit part"
 msgstr "Vorlage bearbeiten"
 
-#: part/templates/part/part_base.html:75
+#: part/templates/part/part_base.html:84
 #, fuzzy
 #| msgid "Delete Parts"
 msgid "Delete part"
 msgstr "Teile löschen"
 
-#: part/templates/part/part_base.html:114 templates/js/table_filters.html:65
+#: part/templates/part/part_base.html:124 templates/js/table_filters.html:65
 msgid "In Stock"
 msgstr "Auf Lager"
 
-#: part/templates/part/part_base.html:121
+#: part/templates/part/part_base.html:131
 msgid "Allocated to Build Orders"
 msgstr "Zu Bauaufträgen zugeordnet"
 
-#: part/templates/part/part_base.html:128
+#: part/templates/part/part_base.html:138
 msgid "Allocated to Sales Orders"
 msgstr "Zu Aufträgen zugeordnet"
 
-#: part/templates/part/part_base.html:150
+#: part/templates/part/part_base.html:160
 msgid "Can Build"
 msgstr "Herstellbar?"
 
-#: part/templates/part/part_base.html:156
+#: part/templates/part/part_base.html:166
 msgid "Underway"
 msgstr "unterwegs"
 
@@ -2923,7 +2942,7 @@ msgstr "Aus vorhandenen Bildern auswählen"
 msgid "Upload new image"
 msgstr "Neues Bild hochladen"
 
-#: part/templates/part/sale_prices.html:9 part/templates/part/tabs.html:50
+#: part/templates/part/sale_prices.html:9 part/templates/part/tabs.html:53
 #, fuzzy
 #| msgid "Price"
 msgid "Sale Price"
@@ -2994,11 +3013,11 @@ msgstr "Varianten"
 msgid "BOM"
 msgstr "Stückliste"
 
-#: part/templates/part/tabs.html:34
+#: part/templates/part/tabs.html:37
 msgid "Used In"
 msgstr "Benutzt in"
 
-#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:282
+#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:282
 msgid "Tests"
 msgstr ""
 
@@ -3032,184 +3051,184 @@ msgstr "Neues Teil hinzufügen"
 msgid "New Variant"
 msgstr "Varianten"
 
-#: part/views.py:76
+#: part/views.py:78
 msgid "Add part attachment"
 msgstr "Teilanhang hinzufügen"
 
-#: part/views.py:125 templates/attachment_table.html:30
+#: part/views.py:129 templates/attachment_table.html:30
 msgid "Edit attachment"
 msgstr "Anhang bearbeiten"
 
-#: part/views.py:129
+#: part/views.py:135
 msgid "Part attachment updated"
 msgstr "Teilanhang aktualisiert"
 
-#: part/views.py:144
+#: part/views.py:150
 msgid "Delete Part Attachment"
 msgstr "Teilanhang löschen"
 
-#: part/views.py:150
+#: part/views.py:158
 msgid "Deleted part attachment"
 msgstr "Teilanhang gelöscht"
 
-#: part/views.py:159
+#: part/views.py:167
 #, fuzzy
 #| msgid "Create Part Parameter Template"
 msgid "Create Test Template"
 msgstr "Teilparametervorlage anlegen"
 
-#: part/views.py:186
+#: part/views.py:196
 #, fuzzy
 #| msgid "Edit Template"
 msgid "Edit Test Template"
 msgstr "Vorlage bearbeiten"
 
-#: part/views.py:200
+#: part/views.py:212
 #, fuzzy
 #| msgid "Delete Template"
 msgid "Delete Test Template"
 msgstr "Vorlage löschen"
 
-#: part/views.py:207
+#: part/views.py:221
 msgid "Set Part Category"
 msgstr "Teilkategorie auswählen"
 
-#: part/views.py:255
+#: part/views.py:271
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr "Kategorie für {n} Teile setzen"
 
-#: part/views.py:290
+#: part/views.py:306
 msgid "Create Variant"
 msgstr "Variante anlegen"
 
-#: part/views.py:368
+#: part/views.py:386
 msgid "Duplicate Part"
 msgstr "Teil duplizieren"
 
-#: part/views.py:373
+#: part/views.py:393
 msgid "Copied part"
 msgstr "Teil kopiert"
 
-#: part/views.py:496
+#: part/views.py:518
 msgid "Created new part"
 msgstr "Neues Teil angelegt"
 
-#: part/views.py:707
+#: part/views.py:733
 msgid "Part QR Code"
 msgstr "Teil-QR-Code"
 
-#: part/views.py:724
+#: part/views.py:752
 msgid "Upload Part Image"
 msgstr "Teilbild hochladen"
 
-#: part/views.py:729 part/views.py:764
+#: part/views.py:760 part/views.py:797
 msgid "Updated part image"
 msgstr "Teilbild aktualisiert"
 
-#: part/views.py:738
+#: part/views.py:769
 msgid "Select Part Image"
 msgstr "Teilbild auswählen"
 
-#: part/views.py:767
+#: part/views.py:800
 msgid "Part image not found"
 msgstr "Teilbild nicht gefunden"
 
-#: part/views.py:778
+#: part/views.py:811
 msgid "Edit Part Properties"
 msgstr "Teileigenschaften bearbeiten"
 
-#: part/views.py:800
+#: part/views.py:835
 msgid "Validate BOM"
 msgstr "BOM validieren"
 
-#: part/views.py:963
+#: part/views.py:1002
 msgid "No BOM file provided"
 msgstr "Keine Stückliste angegeben"
 
-#: part/views.py:1313
+#: part/views.py:1352
 msgid "Enter a valid quantity"
 msgstr "Bitte eine gültige Anzahl eingeben"
 
-#: part/views.py:1338 part/views.py:1341
+#: part/views.py:1377 part/views.py:1380
 msgid "Select valid part"
 msgstr "Bitte ein gültiges Teil auswählen"
 
-#: part/views.py:1347
+#: part/views.py:1386
 msgid "Duplicate part selected"
 msgstr "Teil doppelt ausgewählt"
 
-#: part/views.py:1385
+#: part/views.py:1424
 msgid "Select a part"
 msgstr "Teil auswählen"
 
-#: part/views.py:1391
+#: part/views.py:1430
 #, fuzzy
 #| msgid "Select part to be used in BOM"
 msgid "Selected part creates a circular BOM"
 msgstr "Teil für die Nutzung in der Stückliste auswählen"
 
-#: part/views.py:1395
+#: part/views.py:1434
 msgid "Specify quantity"
 msgstr "Anzahl angeben"
 
-#: part/views.py:1645
+#: part/views.py:1690
 msgid "Confirm Part Deletion"
 msgstr "Löschen des Teils bestätigen"
 
-#: part/views.py:1652
+#: part/views.py:1699
 msgid "Part was deleted"
 msgstr "Teil wurde gelöscht"
 
-#: part/views.py:1661
+#: part/views.py:1708
 msgid "Part Pricing"
 msgstr "Teilbepreisung"
 
-#: part/views.py:1783
+#: part/views.py:1834
 msgid "Create Part Parameter Template"
 msgstr "Teilparametervorlage anlegen"
 
-#: part/views.py:1791
+#: part/views.py:1844
 msgid "Edit Part Parameter Template"
 msgstr "Teilparametervorlage bearbeiten"
 
-#: part/views.py:1798
+#: part/views.py:1853
 msgid "Delete Part Parameter Template"
 msgstr "Teilparametervorlage löschen"
 
-#: part/views.py:1806
+#: part/views.py:1863
 msgid "Create Part Parameter"
 msgstr "Teilparameter anlegen"
 
-#: part/views.py:1856
+#: part/views.py:1915
 msgid "Edit Part Parameter"
 msgstr "Teilparameter bearbeiten"
 
-#: part/views.py:1870
+#: part/views.py:1931
 msgid "Delete Part Parameter"
 msgstr "Teilparameter löschen"
 
-#: part/views.py:1927
+#: part/views.py:1990
 msgid "Edit Part Category"
 msgstr "Teilkategorie bearbeiten"
 
-#: part/views.py:1962
+#: part/views.py:2027
 msgid "Delete Part Category"
 msgstr "Teilkategorie löschen"
 
-#: part/views.py:1968
+#: part/views.py:2035
 msgid "Part category was deleted"
 msgstr "Teilekategorie wurde gelöscht"
 
-#: part/views.py:2027
+#: part/views.py:2098
 msgid "Create BOM item"
 msgstr "BOM-Position anlegen"
 
-#: part/views.py:2093
+#: part/views.py:2166
 msgid "Edit BOM item"
 msgstr "BOM-Position beaarbeiten"
 
-#: part/views.py:2141
+#: part/views.py:2216
 msgid "Confim BOM item deletion"
 msgstr "Löschung von BOM-Position bestätigen"
 
@@ -3653,15 +3672,15 @@ msgid "Stock adjustment actions"
 msgstr "Bestands-Anpassung bestätigen"
 
 #: stock/templates/stock/item_base.html:98
-#: stock/templates/stock/location.html:38 templates/stock_table.html:15
+#: stock/templates/stock/location.html:38 templates/stock_table.html:19
 msgid "Count stock"
 msgstr "Bestand zählen"
 
-#: stock/templates/stock/item_base.html:99 templates/stock_table.html:13
+#: stock/templates/stock/item_base.html:99 templates/stock_table.html:17
 msgid "Add stock"
 msgstr "Bestand hinzufügen"
 
-#: stock/templates/stock/item_base.html:100 templates/stock_table.html:14
+#: stock/templates/stock/item_base.html:100 templates/stock_table.html:18
 msgid "Remove stock"
 msgstr "Bestand entfernen"
 
@@ -4191,6 +4210,14 @@ msgstr "Lagerbestands-Tracking-Eintrag bearbeiten"
 msgid "Add Stock Tracking Entry"
 msgstr "Lagerbestands-Tracking-Eintrag hinzufügen"
 
+#: templates/403.html:5 templates/403.html:11
+msgid "Permission Denied"
+msgstr ""
+
+#: templates/403.html:14
+msgid "You do not have permission to view this page."
+msgstr ""
+
 #: templates/InvenTree/bom_invalid.html:7
 msgid "BOM Waiting Validation"
 msgstr ""
@@ -4201,6 +4228,10 @@ msgstr ""
 msgid "Pending Builds"
 msgstr "Eltern-Bau"
 
+#: templates/InvenTree/index.html:4
+msgid "Index"
+msgstr ""
+
 #: templates/InvenTree/latest_parts.html:7
 #, fuzzy
 #| msgid "Parent Part"
@@ -4884,39 +4915,39 @@ msgstr "Favorit"
 msgid "Purchasable"
 msgstr "Käuflich"
 
-#: templates/navbar.html:22
+#: templates/navbar.html:29
 msgid "Buy"
 msgstr "Kaufen"
 
-#: templates/navbar.html:30
+#: templates/navbar.html:39
 msgid "Sell"
 msgstr "Verkaufen"
 
-#: templates/navbar.html:40
+#: templates/navbar.html:50
 msgid "Scan Barcode"
 msgstr ""
 
-#: templates/navbar.html:49
+#: templates/navbar.html:59 users/models.py:27
 msgid "Admin"
 msgstr "Admin"
 
-#: templates/navbar.html:52
+#: templates/navbar.html:62
 msgid "Settings"
 msgstr "Einstellungen"
 
-#: templates/navbar.html:53
+#: templates/navbar.html:63
 msgid "Logout"
 msgstr "Ausloggen"
 
-#: templates/navbar.html:55
+#: templates/navbar.html:65
 msgid "Login"
 msgstr "Einloggen"
 
-#: templates/navbar.html:58
+#: templates/navbar.html:68
 msgid "About InvenTree"
 msgstr "Über InvenBaum"
 
-#: templates/navbar.html:59
+#: templates/navbar.html:69
 msgid "Statistics"
 msgstr "Statistiken"
 
@@ -4930,54 +4961,124 @@ msgstr "Suche"
 msgid "Export Stock Information"
 msgstr "Lagerobjekt-Standort bearbeiten"
 
-#: templates/stock_table.html:13
+#: templates/stock_table.html:17
 #, fuzzy
 #| msgid "Added stock to {n} items"
 msgid "Add to selected stock items"
 msgstr "Vorrat zu {n} Lagerobjekten hinzugefügt"
 
-#: templates/stock_table.html:14
+#: templates/stock_table.html:18
 #, fuzzy
 #| msgid "Remove selected BOM items"
 msgid "Remove from selected stock items"
 msgstr "Ausgewählte Stücklistenpositionen entfernen"
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:19
 #, fuzzy
 #| msgid "Delete Stock Item"
 msgid "Stocktake selected stock items"
 msgstr "Lagerobjekt löschen"
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:20
 #, fuzzy
 #| msgid "Delete Stock Item"
 msgid "Move selected stock items"
 msgstr "Lagerobjekt löschen"
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:20
 msgid "Move stock"
 msgstr "Bestand bewegen"
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:21
 #, fuzzy
 #| msgid "Remove selected BOM items"
 msgid "Order selected items"
 msgstr "Ausgewählte Stücklistenpositionen entfernen"
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:21
 msgid "Order stock"
 msgstr "Bestand bestellen"
 
-#: templates/stock_table.html:18
+#: templates/stock_table.html:24
 #, fuzzy
 #| msgid "Delete line item"
 msgid "Delete selected items"
 msgstr "Position löschen"
 
-#: templates/stock_table.html:18
+#: templates/stock_table.html:24
 msgid "Delete Stock"
 msgstr "Bestand löschen"
 
+#: users/admin.py:62
+#, fuzzy
+#| msgid "User"
+msgid "Users"
+msgstr "Benutzer"
+
+#: users/admin.py:63
+msgid "Select which users are assigned to this group"
+msgstr ""
+
+#: users/admin.py:124
+#, fuzzy
+#| msgid "External Link"
+msgid "Personal info"
+msgstr "Externer Link"
+
+#: users/admin.py:125
+#, fuzzy
+#| msgid "Revision"
+msgid "Permissions"
+msgstr "Revision"
+
+#: users/admin.py:128
+#, fuzzy
+#| msgid "Import BOM data"
+msgid "Important dates"
+msgstr "Stückliste importieren"
+
+#: users/models.py:124
+msgid "Permission set"
+msgstr ""
+
+#: users/models.py:132
+msgid "Group"
+msgstr ""
+
+#: users/models.py:135
+msgid "View"
+msgstr ""
+
+#: users/models.py:135
+msgid "Permission to view items"
+msgstr ""
+
+#: users/models.py:137
+#, fuzzy
+#| msgid "Created"
+msgid "Create"
+msgstr "Erstellt"
+
+#: users/models.py:137
+msgid "Permission to add items"
+msgstr ""
+
+#: users/models.py:139
+#, fuzzy
+#| msgid "Last Updated"
+msgid "Update"
+msgstr "Zuletzt aktualisiert"
+
+#: users/models.py:139
+msgid "Permissions to edit items"
+msgstr ""
+
+#: users/models.py:141
+#, fuzzy
+#| msgid "Remove selected BOM items"
+msgid "Permission to delete items"
+msgstr "Ausgewählte Stücklistenpositionen entfernen"
+
 #~ msgid "Belongs To"
 #~ msgstr "Gehört zu"
 
diff --git a/InvenTree/locale/en/LC_MESSAGES/django.po b/InvenTree/locale/en/LC_MESSAGES/django.po
index 0b5425db6e..0f38022f92 100644
--- a/InvenTree/locale/en/LC_MESSAGES/django.po
+++ b/InvenTree/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-10-04 14:02+0000\n"
+"POT-Creation-Date: 2020-10-05 13:20+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -90,7 +90,7 @@ msgstr ""
 msgid "User"
 msgstr ""
 
-#: InvenTree/models.py:106 part/templates/part/params.html:20
+#: InvenTree/models.py:106 part/templates/part/params.html:22
 #: templates/js/part.html:81
 msgid "Name"
 msgstr ""
@@ -99,19 +99,19 @@ msgstr ""
 msgid "Description (optional)"
 msgstr ""
 
-#: InvenTree/settings.py:342
+#: InvenTree/settings.py:343
 msgid "English"
 msgstr ""
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:344
 msgid "German"
 msgstr ""
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:345
 msgid "French"
 msgstr ""
 
-#: InvenTree/settings.py:345
+#: InvenTree/settings.py:346
 msgid "Polish"
 msgstr ""
 
@@ -325,7 +325,7 @@ msgstr ""
 msgid "Number of parts to build"
 msgstr ""
 
-#: build/models.py:128 part/templates/part/part_base.html:145
+#: build/models.py:128 part/templates/part/part_base.html:155
 msgid "Build Status"
 msgstr ""
 
@@ -344,7 +344,7 @@ msgstr ""
 #: build/models.py:155 build/templates/build/detail.html:55
 #: company/templates/company/supplier_part_base.html:60
 #: company/templates/company/supplier_part_detail.html:24
-#: part/templates/part/detail.html:80 part/templates/part/part_base.html:92
+#: part/templates/part/detail.html:80 part/templates/part/part_base.html:102
 #: stock/models.py:381 stock/templates/stock/item_base.html:244
 msgid "External Link"
 msgstr ""
@@ -356,7 +356,7 @@ msgstr ""
 #: build/models.py:160 build/templates/build/tabs.html:14 company/models.py:310
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:15
 #: order/templates/order/purchase_order_detail.html:202
-#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:67
+#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:70
 #: stock/forms.py:306 stock/forms.py:338 stock/forms.py:366 stock/models.py:453
 #: stock/models.py:1404 stock/templates/stock/tabs.html:26
 #: templates/js/barcode.html:391 templates/js/bom.html:223
@@ -404,7 +404,7 @@ msgstr ""
 
 #: build/templates/build/allocate.html:17
 #: company/templates/company/detail_part.html:18 order/views.py:779
-#: part/templates/part/category.html:112
+#: part/templates/part/category.html:122
 msgid "Order Parts"
 msgstr ""
 
@@ -420,7 +420,7 @@ msgstr ""
 msgid "Unallocate"
 msgstr ""
 
-#: build/templates/build/allocate.html:87 templates/stock_table.html:9
+#: build/templates/build/allocate.html:87 templates/stock_table.html:10
 msgid "New Stock Item"
 msgstr ""
 
@@ -548,7 +548,7 @@ msgstr ""
 #: build/templates/build/build_base.html:34
 #: build/templates/build/complete.html:6
 #: stock/templates/stock/item_base.html:223 templates/js/build.html:39
-#: templates/navbar.html:20
+#: templates/navbar.html:25
 msgid "Build"
 msgstr ""
 
@@ -687,7 +687,7 @@ msgstr ""
 
 #: build/templates/build/index.html:6 build/templates/build/index.html:14
 #: order/templates/order/so_builds.html:11 order/templates/order/so_tabs.html:9
-#: part/templates/part/tabs.html:30
+#: part/templates/part/tabs.html:31 users/models.py:30
 msgid "Build Orders"
 msgstr ""
 
@@ -709,7 +709,7 @@ msgstr ""
 #: build/templates/build/notes.html:33 company/templates/company/notes.html:30
 #: order/templates/order/order_notes.html:32
 #: order/templates/order/sales_order_notes.html:37
-#: part/templates/part/notes.html:32 stock/templates/stock/item_notes.html:33
+#: part/templates/part/notes.html:33 stock/templates/stock/item_notes.html:33
 msgid "Edit notes"
 msgstr ""
 
@@ -1044,13 +1044,13 @@ msgid "New Supplier Part"
 msgstr ""
 
 #: company/templates/company/detail_part.html:15
-#: part/templates/part/category.html:109 part/templates/part/supplier.html:15
-#: templates/stock_table.html:11
+#: part/templates/part/category.html:117 part/templates/part/supplier.html:15
+#: templates/stock_table.html:14
 msgid "Options"
 msgstr ""
 
 #: company/templates/company/detail_part.html:18
-#: part/templates/part/category.html:112
+#: part/templates/part/category.html:122
 msgid "Order parts"
 msgstr ""
 
@@ -1063,7 +1063,7 @@ msgid "Delete Parts"
 msgstr ""
 
 #: company/templates/company/detail_part.html:43
-#: part/templates/part/category.html:107 templates/js/stock.html:791
+#: part/templates/part/category.html:114 templates/js/stock.html:791
 msgid "New Part"
 msgstr ""
 
@@ -1095,7 +1095,7 @@ msgstr ""
 
 #: company/templates/company/detail_stock.html:35
 #: company/templates/company/supplier_part_stock.html:33
-#: part/templates/part/category.html:106 part/templates/part/category.html:113
+#: part/templates/part/category.html:112 part/templates/part/category.html:123
 #: part/templates/part/stock.html:51 templates/stock_table.html:6
 msgid "Export"
 msgstr ""
@@ -1117,8 +1117,8 @@ msgstr ""
 #: company/templates/company/tabs.html:17
 #: order/templates/order/purchase_orders.html:7
 #: order/templates/order/purchase_orders.html:12
-#: part/templates/part/orders.html:9 part/templates/part/tabs.html:45
-#: templates/navbar.html:26
+#: part/templates/part/orders.html:9 part/templates/part/tabs.html:48
+#: templates/navbar.html:33 users/models.py:31
 msgid "Purchase Orders"
 msgstr ""
 
@@ -1136,8 +1136,8 @@ msgstr ""
 #: company/templates/company/tabs.html:22
 #: order/templates/order/sales_orders.html:7
 #: order/templates/order/sales_orders.html:12
-#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:53
-#: templates/navbar.html:33
+#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:56
+#: templates/navbar.html:42 users/models.py:32
 msgid "Sales Orders"
 msgstr ""
 
@@ -1158,7 +1158,7 @@ msgid "Supplier Part"
 msgstr ""
 
 #: company/templates/company/supplier_part_base.html:23
-#: part/templates/part/orders.html:14
+#: part/templates/part/orders.html:14 part/templates/part/part_base.html:66
 msgid "Order part"
 msgstr ""
 
@@ -1205,7 +1205,7 @@ msgid "Pricing Information"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2149
+#: part/templates/part/sale_prices.html:13 part/views.py:2226
 msgid "Add Price Break"
 msgstr ""
 
@@ -1241,7 +1241,7 @@ msgstr ""
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
 #: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
 #: templates/js/part.html:124 templates/js/part.html:372
-#: templates/js/stock.html:452 templates/navbar.html:19
+#: templates/js/stock.html:452 templates/navbar.html:22 users/models.py:29
 msgid "Stock"
 msgstr ""
 
@@ -1251,22 +1251,22 @@ msgstr ""
 
 #: company/templates/company/tabs.html:9
 #: order/templates/order/receive_parts.html:14 part/models.py:294
-#: part/templates/part/cat_link.html:7 part/templates/part/category.html:88
-#: part/templates/part/category_tabs.html:6 templates/navbar.html:18
-#: templates/stats.html:8 templates/stats.html:17
+#: part/templates/part/cat_link.html:7 part/templates/part/category.html:94
+#: part/templates/part/category_tabs.html:6 templates/navbar.html:19
+#: templates/stats.html:8 templates/stats.html:17 users/models.py:28
 msgid "Parts"
 msgstr ""
 
-#: company/views.py:50 part/templates/part/tabs.html:39
-#: templates/navbar.html:24
+#: company/views.py:50 part/templates/part/tabs.html:42
+#: templates/navbar.html:31
 msgid "Suppliers"
 msgstr ""
 
-#: company/views.py:57 templates/navbar.html:25
+#: company/views.py:57 templates/navbar.html:32
 msgid "Manufacturers"
 msgstr ""
 
-#: company/views.py:64 templates/navbar.html:32
+#: company/views.py:64 templates/navbar.html:41
 msgid "Customers"
 msgstr ""
 
@@ -1330,15 +1330,15 @@ msgstr ""
 msgid "Delete Supplier Part"
 msgstr ""
 
-#: company/views.py:404 part/views.py:2153
+#: company/views.py:404 part/views.py:2232
 msgid "Added new price break"
 msgstr ""
 
-#: company/views.py:441 part/views.py:2198
+#: company/views.py:441 part/views.py:2277
 msgid "Edit Price Break"
 msgstr ""
 
-#: company/views.py:456 part/views.py:2212
+#: company/views.py:456 part/views.py:2293
 msgid "Delete Price Break"
 msgstr ""
 
@@ -1431,7 +1431,7 @@ msgstr ""
 msgid "Date order was completed"
 msgstr ""
 
-#: order/models.py:185 order/models.py:259 part/views.py:1304
+#: order/models.py:185 order/models.py:259 part/views.py:1343
 #: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr ""
@@ -1600,7 +1600,7 @@ msgid "Purchase Order Attachments"
 msgstr ""
 
 #: order/templates/order/po_tabs.html:8 order/templates/order/so_tabs.html:16
-#: part/templates/part/tabs.html:64 stock/templates/stock/tabs.html:32
+#: part/templates/part/tabs.html:67 stock/templates/stock/tabs.html:32
 msgid "Attachments"
 msgstr ""
 
@@ -1616,7 +1616,7 @@ msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:38
 #: order/templates/order/purchase_order_detail.html:118
-#: part/templates/part/category.html:161 part/templates/part/category.html:202
+#: part/templates/part/category.html:171 part/templates/part/category.html:213
 #: templates/js/stock.html:803
 msgid "New Location"
 msgstr ""
@@ -1658,7 +1658,7 @@ msgid "Select parts to receive against this order"
 msgstr ""
 
 #: order/templates/order/receive_parts.html:21
-#: part/templates/part/part_base.html:135 templates/js/part.html:388
+#: part/templates/part/part_base.html:145 templates/js/part.html:388
 msgid "On Order"
 msgstr ""
 
@@ -1750,7 +1750,7 @@ msgstr ""
 msgid "Add Purchase Order Attachment"
 msgstr ""
 
-#: order/views.py:102 order/views.py:149 part/views.py:86 stock/views.py:167
+#: order/views.py:102 order/views.py:149 part/views.py:90 stock/views.py:167
 msgid "Added attachment"
 msgstr ""
 
@@ -1890,12 +1890,12 @@ msgstr ""
 msgid "Remove allocation"
 msgstr ""
 
-#: part/bom.py:138 part/templates/part/category.html:55
+#: part/bom.py:138 part/templates/part/category.html:61
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr ""
 
-#: part/bom.py:139 part/templates/part/part_base.html:108
+#: part/bom.py:139 part/templates/part/part_base.html:118
 msgid "Available Stock"
 msgstr ""
 
@@ -2013,7 +2013,7 @@ msgid "Part Category"
 msgstr ""
 
 #: part/models.py:76 part/templates/part/category.html:18
-#: part/templates/part/category.html:83 templates/stats.html:12
+#: part/templates/part/category.html:89 templates/stats.html:12
 msgid "Part Categories"
 msgstr ""
 
@@ -2226,7 +2226,7 @@ msgstr ""
 msgid "BOM line checksum"
 msgstr ""
 
-#: part/models.py:1612 part/views.py:1310 part/views.py:1362
+#: part/models.py:1612 part/views.py:1349 part/views.py:1401
 #: stock/models.py:231
 msgid "Quantity must be integer value for trackable parts"
 msgstr ""
@@ -2285,23 +2285,23 @@ msgstr ""
 msgid "Finish Editing"
 msgstr ""
 
-#: part/templates/part/bom.html:42
+#: part/templates/part/bom.html:43
 msgid "Edit BOM"
 msgstr ""
 
-#: part/templates/part/bom.html:44
+#: part/templates/part/bom.html:45
 msgid "Validate Bill of Materials"
 msgstr ""
 
-#: part/templates/part/bom.html:46 part/views.py:1597
+#: part/templates/part/bom.html:48 part/views.py:1640
 msgid "Export Bill of Materials"
 msgstr ""
 
-#: part/templates/part/bom.html:101
+#: part/templates/part/bom.html:103
 msgid "Delete selected BOM items?"
 msgstr ""
 
-#: part/templates/part/bom.html:102
+#: part/templates/part/bom.html:104
 msgid "All selected BOM items will be deleted"
 msgstr ""
 
@@ -2373,83 +2373,91 @@ msgstr ""
 msgid "Each part must already exist in the database"
 msgstr ""
 
+#: part/templates/part/build.html:8
+msgid "Part Builds"
+msgstr ""
+
+#: part/templates/part/build.html:14
+msgid "Start New Build"
+msgstr ""
+
 #: part/templates/part/category.html:19
 msgid "All parts"
 msgstr ""
 
-#: part/templates/part/category.html:23 part/views.py:1976
+#: part/templates/part/category.html:24 part/views.py:2043
 msgid "Create new part category"
 msgstr ""
 
-#: part/templates/part/category.html:27
+#: part/templates/part/category.html:30
 msgid "Edit part category"
 msgstr ""
 
-#: part/templates/part/category.html:30
+#: part/templates/part/category.html:35
 msgid "Delete part category"
 msgstr ""
 
-#: part/templates/part/category.html:39 part/templates/part/category.html:78
+#: part/templates/part/category.html:45 part/templates/part/category.html:84
 msgid "Category Details"
 msgstr ""
 
-#: part/templates/part/category.html:44
+#: part/templates/part/category.html:50
 msgid "Category Path"
 msgstr ""
 
-#: part/templates/part/category.html:49
+#: part/templates/part/category.html:55
 msgid "Category Description"
 msgstr ""
 
-#: part/templates/part/category.html:62 part/templates/part/detail.html:64
+#: part/templates/part/category.html:68 part/templates/part/detail.html:64
 msgid "Keywords"
 msgstr ""
 
-#: part/templates/part/category.html:68
+#: part/templates/part/category.html:74
 msgid "Subcategories"
 msgstr ""
 
-#: part/templates/part/category.html:73
+#: part/templates/part/category.html:79
 msgid "Parts (Including subcategories)"
 msgstr ""
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:112
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:107 part/views.py:491
+#: part/templates/part/category.html:114 part/views.py:511
 msgid "Create new part"
 msgstr ""
 
-#: part/templates/part/category.html:111
+#: part/templates/part/category.html:120
 msgid "Set category"
 msgstr ""
 
-#: part/templates/part/category.html:111
+#: part/templates/part/category.html:120
 msgid "Set Category"
 msgstr ""
 
-#: part/templates/part/category.html:113
+#: part/templates/part/category.html:123
 msgid "Export Data"
 msgstr ""
 
-#: part/templates/part/category.html:162
+#: part/templates/part/category.html:172
 msgid "Create new location"
 msgstr ""
 
-#: part/templates/part/category.html:167 part/templates/part/category.html:196
+#: part/templates/part/category.html:177 part/templates/part/category.html:207
 msgid "New Category"
 msgstr ""
 
-#: part/templates/part/category.html:168
+#: part/templates/part/category.html:178
 msgid "Create new category"
 msgstr ""
 
-#: part/templates/part/category.html:197
+#: part/templates/part/category.html:208
 msgid "Create new Part Category"
 msgstr ""
 
-#: part/templates/part/category.html:203 stock/views.py:1314
+#: part/templates/part/category.html:214 stock/views.py:1314
 msgid "Create new Stock Location"
 msgstr ""
 
@@ -2461,7 +2469,7 @@ msgstr ""
 msgid "Part Details"
 msgstr ""
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:85
+#: part/templates/part/detail.html:25 part/templates/part/part_base.html:95
 #: templates/js/part.html:112
 msgid "IPN"
 msgstr ""
@@ -2491,7 +2499,7 @@ msgstr ""
 msgid "Default Supplier"
 msgstr ""
 
-#: part/templates/part/detail.html:102 part/templates/part/params.html:22
+#: part/templates/part/detail.html:102 part/templates/part/params.html:24
 msgid "Units"
 msgstr ""
 
@@ -2616,24 +2624,25 @@ msgstr ""
 msgid "Part Parameters"
 msgstr ""
 
-#: part/templates/part/params.html:13
+#: part/templates/part/params.html:14
 msgid "Add new parameter"
 msgstr ""
 
-#: part/templates/part/params.html:13 templates/InvenTree/settings/part.html:12
+#: part/templates/part/params.html:14 templates/InvenTree/settings/part.html:12
 msgid "New Parameter"
 msgstr ""
 
-#: part/templates/part/params.html:21 stock/models.py:1391
+#: part/templates/part/params.html:23 stock/models.py:1391
 #: templates/js/stock.html:112
 msgid "Value"
 msgstr ""
 
-#: part/templates/part/params.html:33
+#: part/templates/part/params.html:36
 msgid "Edit"
 msgstr ""
 
-#: part/templates/part/params.html:34 part/templates/part/supplier.html:17
+#: part/templates/part/params.html:39 part/templates/part/supplier.html:17
+#: users/models.py:141
 msgid "Delete"
 msgstr ""
 
@@ -2684,39 +2693,43 @@ msgstr ""
 msgid "Show pricing information"
 msgstr ""
 
-#: part/templates/part/part_base.html:70
-msgid "Part actions"
-msgstr ""
-
-#: part/templates/part/part_base.html:72
-msgid "Duplicate part"
-msgstr ""
-
-#: part/templates/part/part_base.html:73
-msgid "Edit part"
+#: part/templates/part/part_base.html:60
+msgid "Count part stock"
 msgstr ""
 
 #: part/templates/part/part_base.html:75
+msgid "Part actions"
+msgstr ""
+
+#: part/templates/part/part_base.html:78
+msgid "Duplicate part"
+msgstr ""
+
+#: part/templates/part/part_base.html:81
+msgid "Edit part"
+msgstr ""
+
+#: part/templates/part/part_base.html:84
 msgid "Delete part"
 msgstr ""
 
-#: part/templates/part/part_base.html:114 templates/js/table_filters.html:65
+#: part/templates/part/part_base.html:124 templates/js/table_filters.html:65
 msgid "In Stock"
 msgstr ""
 
-#: part/templates/part/part_base.html:121
+#: part/templates/part/part_base.html:131
 msgid "Allocated to Build Orders"
 msgstr ""
 
-#: part/templates/part/part_base.html:128
+#: part/templates/part/part_base.html:138
 msgid "Allocated to Sales Orders"
 msgstr ""
 
-#: part/templates/part/part_base.html:150
+#: part/templates/part/part_base.html:160
 msgid "Can Build"
 msgstr ""
 
-#: part/templates/part/part_base.html:156
+#: part/templates/part/part_base.html:166
 msgid "Underway"
 msgstr ""
 
@@ -2736,7 +2749,7 @@ msgstr ""
 msgid "Upload new image"
 msgstr ""
 
-#: part/templates/part/sale_prices.html:9 part/templates/part/tabs.html:50
+#: part/templates/part/sale_prices.html:9 part/templates/part/tabs.html:53
 msgid "Sale Price"
 msgstr ""
 
@@ -2797,11 +2810,11 @@ msgstr ""
 msgid "BOM"
 msgstr ""
 
-#: part/templates/part/tabs.html:34
+#: part/templates/part/tabs.html:37
 msgid "Used In"
 msgstr ""
 
-#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:282
+#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:282
 msgid "Tests"
 msgstr ""
 
@@ -2829,176 +2842,176 @@ msgstr ""
 msgid "New Variant"
 msgstr ""
 
-#: part/views.py:76
+#: part/views.py:78
 msgid "Add part attachment"
 msgstr ""
 
-#: part/views.py:125 templates/attachment_table.html:30
+#: part/views.py:129 templates/attachment_table.html:30
 msgid "Edit attachment"
 msgstr ""
 
-#: part/views.py:129
+#: part/views.py:135
 msgid "Part attachment updated"
 msgstr ""
 
-#: part/views.py:144
+#: part/views.py:150
 msgid "Delete Part Attachment"
 msgstr ""
 
-#: part/views.py:150
+#: part/views.py:158
 msgid "Deleted part attachment"
 msgstr ""
 
-#: part/views.py:159
+#: part/views.py:167
 msgid "Create Test Template"
 msgstr ""
 
-#: part/views.py:186
+#: part/views.py:196
 msgid "Edit Test Template"
 msgstr ""
 
-#: part/views.py:200
+#: part/views.py:212
 msgid "Delete Test Template"
 msgstr ""
 
-#: part/views.py:207
+#: part/views.py:221
 msgid "Set Part Category"
 msgstr ""
 
-#: part/views.py:255
+#: part/views.py:271
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr ""
 
-#: part/views.py:290
+#: part/views.py:306
 msgid "Create Variant"
 msgstr ""
 
-#: part/views.py:368
+#: part/views.py:386
 msgid "Duplicate Part"
 msgstr ""
 
-#: part/views.py:373
+#: part/views.py:393
 msgid "Copied part"
 msgstr ""
 
-#: part/views.py:496
+#: part/views.py:518
 msgid "Created new part"
 msgstr ""
 
-#: part/views.py:707
+#: part/views.py:733
 msgid "Part QR Code"
 msgstr ""
 
-#: part/views.py:724
+#: part/views.py:752
 msgid "Upload Part Image"
 msgstr ""
 
-#: part/views.py:729 part/views.py:764
+#: part/views.py:760 part/views.py:797
 msgid "Updated part image"
 msgstr ""
 
-#: part/views.py:738
+#: part/views.py:769
 msgid "Select Part Image"
 msgstr ""
 
-#: part/views.py:767
+#: part/views.py:800
 msgid "Part image not found"
 msgstr ""
 
-#: part/views.py:778
+#: part/views.py:811
 msgid "Edit Part Properties"
 msgstr ""
 
-#: part/views.py:800
+#: part/views.py:835
 msgid "Validate BOM"
 msgstr ""
 
-#: part/views.py:963
+#: part/views.py:1002
 msgid "No BOM file provided"
 msgstr ""
 
-#: part/views.py:1313
+#: part/views.py:1352
 msgid "Enter a valid quantity"
 msgstr ""
 
-#: part/views.py:1338 part/views.py:1341
+#: part/views.py:1377 part/views.py:1380
 msgid "Select valid part"
 msgstr ""
 
-#: part/views.py:1347
+#: part/views.py:1386
 msgid "Duplicate part selected"
 msgstr ""
 
-#: part/views.py:1385
+#: part/views.py:1424
 msgid "Select a part"
 msgstr ""
 
-#: part/views.py:1391
+#: part/views.py:1430
 msgid "Selected part creates a circular BOM"
 msgstr ""
 
-#: part/views.py:1395
+#: part/views.py:1434
 msgid "Specify quantity"
 msgstr ""
 
-#: part/views.py:1645
+#: part/views.py:1690
 msgid "Confirm Part Deletion"
 msgstr ""
 
-#: part/views.py:1652
+#: part/views.py:1699
 msgid "Part was deleted"
 msgstr ""
 
-#: part/views.py:1661
+#: part/views.py:1708
 msgid "Part Pricing"
 msgstr ""
 
-#: part/views.py:1783
+#: part/views.py:1834
 msgid "Create Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1791
+#: part/views.py:1844
 msgid "Edit Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1798
+#: part/views.py:1853
 msgid "Delete Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1806
+#: part/views.py:1863
 msgid "Create Part Parameter"
 msgstr ""
 
-#: part/views.py:1856
+#: part/views.py:1915
 msgid "Edit Part Parameter"
 msgstr ""
 
-#: part/views.py:1870
+#: part/views.py:1931
 msgid "Delete Part Parameter"
 msgstr ""
 
-#: part/views.py:1927
+#: part/views.py:1990
 msgid "Edit Part Category"
 msgstr ""
 
-#: part/views.py:1962
+#: part/views.py:2027
 msgid "Delete Part Category"
 msgstr ""
 
-#: part/views.py:1968
+#: part/views.py:2035
 msgid "Part category was deleted"
 msgstr ""
 
-#: part/views.py:2027
+#: part/views.py:2098
 msgid "Create BOM item"
 msgstr ""
 
-#: part/views.py:2093
+#: part/views.py:2166
 msgid "Edit BOM item"
 msgstr ""
 
-#: part/views.py:2141
+#: part/views.py:2216
 msgid "Confim BOM item deletion"
 msgstr ""
 
@@ -3371,15 +3384,15 @@ msgid "Stock adjustment actions"
 msgstr ""
 
 #: stock/templates/stock/item_base.html:98
-#: stock/templates/stock/location.html:38 templates/stock_table.html:15
+#: stock/templates/stock/location.html:38 templates/stock_table.html:19
 msgid "Count stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:99 templates/stock_table.html:13
+#: stock/templates/stock/item_base.html:99 templates/stock_table.html:17
 msgid "Add stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:100 templates/stock_table.html:14
+#: stock/templates/stock/item_base.html:100 templates/stock_table.html:18
 msgid "Remove stock"
 msgstr ""
 
@@ -3819,6 +3832,14 @@ msgstr ""
 msgid "Add Stock Tracking Entry"
 msgstr ""
 
+#: templates/403.html:5 templates/403.html:11
+msgid "Permission Denied"
+msgstr ""
+
+#: templates/403.html:14
+msgid "You do not have permission to view this page."
+msgstr ""
+
 #: templates/InvenTree/bom_invalid.html:7
 msgid "BOM Waiting Validation"
 msgstr ""
@@ -3827,6 +3848,10 @@ msgstr ""
 msgid "Pending Builds"
 msgstr ""
 
+#: templates/InvenTree/index.html:4
+msgid "Index"
+msgstr ""
+
 #: templates/InvenTree/latest_parts.html:7
 msgid "Latest Parts"
 msgstr ""
@@ -4392,39 +4417,39 @@ msgstr ""
 msgid "Purchasable"
 msgstr ""
 
-#: templates/navbar.html:22
+#: templates/navbar.html:29
 msgid "Buy"
 msgstr ""
 
-#: templates/navbar.html:30
+#: templates/navbar.html:39
 msgid "Sell"
 msgstr ""
 
-#: templates/navbar.html:40
+#: templates/navbar.html:50
 msgid "Scan Barcode"
 msgstr ""
 
-#: templates/navbar.html:49
+#: templates/navbar.html:59 users/models.py:27
 msgid "Admin"
 msgstr ""
 
-#: templates/navbar.html:52
+#: templates/navbar.html:62
 msgid "Settings"
 msgstr ""
 
-#: templates/navbar.html:53
+#: templates/navbar.html:63
 msgid "Logout"
 msgstr ""
 
-#: templates/navbar.html:55
+#: templates/navbar.html:65
 msgid "Login"
 msgstr ""
 
-#: templates/navbar.html:58
+#: templates/navbar.html:68
 msgid "About InvenTree"
 msgstr ""
 
-#: templates/navbar.html:59
+#: templates/navbar.html:69
 msgid "Statistics"
 msgstr ""
 
@@ -4436,38 +4461,94 @@ msgstr ""
 msgid "Export Stock Information"
 msgstr ""
 
-#: templates/stock_table.html:13
+#: templates/stock_table.html:17
 msgid "Add to selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:14
+#: templates/stock_table.html:18
 msgid "Remove from selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:19
 msgid "Stocktake selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:20
 msgid "Move selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:20
 msgid "Move stock"
 msgstr ""
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:21
 msgid "Order selected items"
 msgstr ""
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:21
 msgid "Order stock"
 msgstr ""
 
-#: templates/stock_table.html:18
+#: templates/stock_table.html:24
 msgid "Delete selected items"
 msgstr ""
 
-#: templates/stock_table.html:18
+#: templates/stock_table.html:24
 msgid "Delete Stock"
 msgstr ""
+
+#: users/admin.py:62
+msgid "Users"
+msgstr ""
+
+#: users/admin.py:63
+msgid "Select which users are assigned to this group"
+msgstr ""
+
+#: users/admin.py:124
+msgid "Personal info"
+msgstr ""
+
+#: users/admin.py:125
+msgid "Permissions"
+msgstr ""
+
+#: users/admin.py:128
+msgid "Important dates"
+msgstr ""
+
+#: users/models.py:124
+msgid "Permission set"
+msgstr ""
+
+#: users/models.py:132
+msgid "Group"
+msgstr ""
+
+#: users/models.py:135
+msgid "View"
+msgstr ""
+
+#: users/models.py:135
+msgid "Permission to view items"
+msgstr ""
+
+#: users/models.py:137
+msgid "Create"
+msgstr ""
+
+#: users/models.py:137
+msgid "Permission to add items"
+msgstr ""
+
+#: users/models.py:139
+msgid "Update"
+msgstr ""
+
+#: users/models.py:139
+msgid "Permissions to edit items"
+msgstr ""
+
+#: users/models.py:141
+msgid "Permission to delete items"
+msgstr ""
diff --git a/InvenTree/locale/es/LC_MESSAGES/django.po b/InvenTree/locale/es/LC_MESSAGES/django.po
index 0b5425db6e..0f38022f92 100644
--- a/InvenTree/locale/es/LC_MESSAGES/django.po
+++ b/InvenTree/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-10-04 14:02+0000\n"
+"POT-Creation-Date: 2020-10-05 13:20+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -90,7 +90,7 @@ msgstr ""
 msgid "User"
 msgstr ""
 
-#: InvenTree/models.py:106 part/templates/part/params.html:20
+#: InvenTree/models.py:106 part/templates/part/params.html:22
 #: templates/js/part.html:81
 msgid "Name"
 msgstr ""
@@ -99,19 +99,19 @@ msgstr ""
 msgid "Description (optional)"
 msgstr ""
 
-#: InvenTree/settings.py:342
+#: InvenTree/settings.py:343
 msgid "English"
 msgstr ""
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:344
 msgid "German"
 msgstr ""
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:345
 msgid "French"
 msgstr ""
 
-#: InvenTree/settings.py:345
+#: InvenTree/settings.py:346
 msgid "Polish"
 msgstr ""
 
@@ -325,7 +325,7 @@ msgstr ""
 msgid "Number of parts to build"
 msgstr ""
 
-#: build/models.py:128 part/templates/part/part_base.html:145
+#: build/models.py:128 part/templates/part/part_base.html:155
 msgid "Build Status"
 msgstr ""
 
@@ -344,7 +344,7 @@ msgstr ""
 #: build/models.py:155 build/templates/build/detail.html:55
 #: company/templates/company/supplier_part_base.html:60
 #: company/templates/company/supplier_part_detail.html:24
-#: part/templates/part/detail.html:80 part/templates/part/part_base.html:92
+#: part/templates/part/detail.html:80 part/templates/part/part_base.html:102
 #: stock/models.py:381 stock/templates/stock/item_base.html:244
 msgid "External Link"
 msgstr ""
@@ -356,7 +356,7 @@ msgstr ""
 #: build/models.py:160 build/templates/build/tabs.html:14 company/models.py:310
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:15
 #: order/templates/order/purchase_order_detail.html:202
-#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:67
+#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:70
 #: stock/forms.py:306 stock/forms.py:338 stock/forms.py:366 stock/models.py:453
 #: stock/models.py:1404 stock/templates/stock/tabs.html:26
 #: templates/js/barcode.html:391 templates/js/bom.html:223
@@ -404,7 +404,7 @@ msgstr ""
 
 #: build/templates/build/allocate.html:17
 #: company/templates/company/detail_part.html:18 order/views.py:779
-#: part/templates/part/category.html:112
+#: part/templates/part/category.html:122
 msgid "Order Parts"
 msgstr ""
 
@@ -420,7 +420,7 @@ msgstr ""
 msgid "Unallocate"
 msgstr ""
 
-#: build/templates/build/allocate.html:87 templates/stock_table.html:9
+#: build/templates/build/allocate.html:87 templates/stock_table.html:10
 msgid "New Stock Item"
 msgstr ""
 
@@ -548,7 +548,7 @@ msgstr ""
 #: build/templates/build/build_base.html:34
 #: build/templates/build/complete.html:6
 #: stock/templates/stock/item_base.html:223 templates/js/build.html:39
-#: templates/navbar.html:20
+#: templates/navbar.html:25
 msgid "Build"
 msgstr ""
 
@@ -687,7 +687,7 @@ msgstr ""
 
 #: build/templates/build/index.html:6 build/templates/build/index.html:14
 #: order/templates/order/so_builds.html:11 order/templates/order/so_tabs.html:9
-#: part/templates/part/tabs.html:30
+#: part/templates/part/tabs.html:31 users/models.py:30
 msgid "Build Orders"
 msgstr ""
 
@@ -709,7 +709,7 @@ msgstr ""
 #: build/templates/build/notes.html:33 company/templates/company/notes.html:30
 #: order/templates/order/order_notes.html:32
 #: order/templates/order/sales_order_notes.html:37
-#: part/templates/part/notes.html:32 stock/templates/stock/item_notes.html:33
+#: part/templates/part/notes.html:33 stock/templates/stock/item_notes.html:33
 msgid "Edit notes"
 msgstr ""
 
@@ -1044,13 +1044,13 @@ msgid "New Supplier Part"
 msgstr ""
 
 #: company/templates/company/detail_part.html:15
-#: part/templates/part/category.html:109 part/templates/part/supplier.html:15
-#: templates/stock_table.html:11
+#: part/templates/part/category.html:117 part/templates/part/supplier.html:15
+#: templates/stock_table.html:14
 msgid "Options"
 msgstr ""
 
 #: company/templates/company/detail_part.html:18
-#: part/templates/part/category.html:112
+#: part/templates/part/category.html:122
 msgid "Order parts"
 msgstr ""
 
@@ -1063,7 +1063,7 @@ msgid "Delete Parts"
 msgstr ""
 
 #: company/templates/company/detail_part.html:43
-#: part/templates/part/category.html:107 templates/js/stock.html:791
+#: part/templates/part/category.html:114 templates/js/stock.html:791
 msgid "New Part"
 msgstr ""
 
@@ -1095,7 +1095,7 @@ msgstr ""
 
 #: company/templates/company/detail_stock.html:35
 #: company/templates/company/supplier_part_stock.html:33
-#: part/templates/part/category.html:106 part/templates/part/category.html:113
+#: part/templates/part/category.html:112 part/templates/part/category.html:123
 #: part/templates/part/stock.html:51 templates/stock_table.html:6
 msgid "Export"
 msgstr ""
@@ -1117,8 +1117,8 @@ msgstr ""
 #: company/templates/company/tabs.html:17
 #: order/templates/order/purchase_orders.html:7
 #: order/templates/order/purchase_orders.html:12
-#: part/templates/part/orders.html:9 part/templates/part/tabs.html:45
-#: templates/navbar.html:26
+#: part/templates/part/orders.html:9 part/templates/part/tabs.html:48
+#: templates/navbar.html:33 users/models.py:31
 msgid "Purchase Orders"
 msgstr ""
 
@@ -1136,8 +1136,8 @@ msgstr ""
 #: company/templates/company/tabs.html:22
 #: order/templates/order/sales_orders.html:7
 #: order/templates/order/sales_orders.html:12
-#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:53
-#: templates/navbar.html:33
+#: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:56
+#: templates/navbar.html:42 users/models.py:32
 msgid "Sales Orders"
 msgstr ""
 
@@ -1158,7 +1158,7 @@ msgid "Supplier Part"
 msgstr ""
 
 #: company/templates/company/supplier_part_base.html:23
-#: part/templates/part/orders.html:14
+#: part/templates/part/orders.html:14 part/templates/part/part_base.html:66
 msgid "Order part"
 msgstr ""
 
@@ -1205,7 +1205,7 @@ msgid "Pricing Information"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2149
+#: part/templates/part/sale_prices.html:13 part/views.py:2226
 msgid "Add Price Break"
 msgstr ""
 
@@ -1241,7 +1241,7 @@ msgstr ""
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
 #: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
 #: templates/js/part.html:124 templates/js/part.html:372
-#: templates/js/stock.html:452 templates/navbar.html:19
+#: templates/js/stock.html:452 templates/navbar.html:22 users/models.py:29
 msgid "Stock"
 msgstr ""
 
@@ -1251,22 +1251,22 @@ msgstr ""
 
 #: company/templates/company/tabs.html:9
 #: order/templates/order/receive_parts.html:14 part/models.py:294
-#: part/templates/part/cat_link.html:7 part/templates/part/category.html:88
-#: part/templates/part/category_tabs.html:6 templates/navbar.html:18
-#: templates/stats.html:8 templates/stats.html:17
+#: part/templates/part/cat_link.html:7 part/templates/part/category.html:94
+#: part/templates/part/category_tabs.html:6 templates/navbar.html:19
+#: templates/stats.html:8 templates/stats.html:17 users/models.py:28
 msgid "Parts"
 msgstr ""
 
-#: company/views.py:50 part/templates/part/tabs.html:39
-#: templates/navbar.html:24
+#: company/views.py:50 part/templates/part/tabs.html:42
+#: templates/navbar.html:31
 msgid "Suppliers"
 msgstr ""
 
-#: company/views.py:57 templates/navbar.html:25
+#: company/views.py:57 templates/navbar.html:32
 msgid "Manufacturers"
 msgstr ""
 
-#: company/views.py:64 templates/navbar.html:32
+#: company/views.py:64 templates/navbar.html:41
 msgid "Customers"
 msgstr ""
 
@@ -1330,15 +1330,15 @@ msgstr ""
 msgid "Delete Supplier Part"
 msgstr ""
 
-#: company/views.py:404 part/views.py:2153
+#: company/views.py:404 part/views.py:2232
 msgid "Added new price break"
 msgstr ""
 
-#: company/views.py:441 part/views.py:2198
+#: company/views.py:441 part/views.py:2277
 msgid "Edit Price Break"
 msgstr ""
 
-#: company/views.py:456 part/views.py:2212
+#: company/views.py:456 part/views.py:2293
 msgid "Delete Price Break"
 msgstr ""
 
@@ -1431,7 +1431,7 @@ msgstr ""
 msgid "Date order was completed"
 msgstr ""
 
-#: order/models.py:185 order/models.py:259 part/views.py:1304
+#: order/models.py:185 order/models.py:259 part/views.py:1343
 #: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr ""
@@ -1600,7 +1600,7 @@ msgid "Purchase Order Attachments"
 msgstr ""
 
 #: order/templates/order/po_tabs.html:8 order/templates/order/so_tabs.html:16
-#: part/templates/part/tabs.html:64 stock/templates/stock/tabs.html:32
+#: part/templates/part/tabs.html:67 stock/templates/stock/tabs.html:32
 msgid "Attachments"
 msgstr ""
 
@@ -1616,7 +1616,7 @@ msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:38
 #: order/templates/order/purchase_order_detail.html:118
-#: part/templates/part/category.html:161 part/templates/part/category.html:202
+#: part/templates/part/category.html:171 part/templates/part/category.html:213
 #: templates/js/stock.html:803
 msgid "New Location"
 msgstr ""
@@ -1658,7 +1658,7 @@ msgid "Select parts to receive against this order"
 msgstr ""
 
 #: order/templates/order/receive_parts.html:21
-#: part/templates/part/part_base.html:135 templates/js/part.html:388
+#: part/templates/part/part_base.html:145 templates/js/part.html:388
 msgid "On Order"
 msgstr ""
 
@@ -1750,7 +1750,7 @@ msgstr ""
 msgid "Add Purchase Order Attachment"
 msgstr ""
 
-#: order/views.py:102 order/views.py:149 part/views.py:86 stock/views.py:167
+#: order/views.py:102 order/views.py:149 part/views.py:90 stock/views.py:167
 msgid "Added attachment"
 msgstr ""
 
@@ -1890,12 +1890,12 @@ msgstr ""
 msgid "Remove allocation"
 msgstr ""
 
-#: part/bom.py:138 part/templates/part/category.html:55
+#: part/bom.py:138 part/templates/part/category.html:61
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr ""
 
-#: part/bom.py:139 part/templates/part/part_base.html:108
+#: part/bom.py:139 part/templates/part/part_base.html:118
 msgid "Available Stock"
 msgstr ""
 
@@ -2013,7 +2013,7 @@ msgid "Part Category"
 msgstr ""
 
 #: part/models.py:76 part/templates/part/category.html:18
-#: part/templates/part/category.html:83 templates/stats.html:12
+#: part/templates/part/category.html:89 templates/stats.html:12
 msgid "Part Categories"
 msgstr ""
 
@@ -2226,7 +2226,7 @@ msgstr ""
 msgid "BOM line checksum"
 msgstr ""
 
-#: part/models.py:1612 part/views.py:1310 part/views.py:1362
+#: part/models.py:1612 part/views.py:1349 part/views.py:1401
 #: stock/models.py:231
 msgid "Quantity must be integer value for trackable parts"
 msgstr ""
@@ -2285,23 +2285,23 @@ msgstr ""
 msgid "Finish Editing"
 msgstr ""
 
-#: part/templates/part/bom.html:42
+#: part/templates/part/bom.html:43
 msgid "Edit BOM"
 msgstr ""
 
-#: part/templates/part/bom.html:44
+#: part/templates/part/bom.html:45
 msgid "Validate Bill of Materials"
 msgstr ""
 
-#: part/templates/part/bom.html:46 part/views.py:1597
+#: part/templates/part/bom.html:48 part/views.py:1640
 msgid "Export Bill of Materials"
 msgstr ""
 
-#: part/templates/part/bom.html:101
+#: part/templates/part/bom.html:103
 msgid "Delete selected BOM items?"
 msgstr ""
 
-#: part/templates/part/bom.html:102
+#: part/templates/part/bom.html:104
 msgid "All selected BOM items will be deleted"
 msgstr ""
 
@@ -2373,83 +2373,91 @@ msgstr ""
 msgid "Each part must already exist in the database"
 msgstr ""
 
+#: part/templates/part/build.html:8
+msgid "Part Builds"
+msgstr ""
+
+#: part/templates/part/build.html:14
+msgid "Start New Build"
+msgstr ""
+
 #: part/templates/part/category.html:19
 msgid "All parts"
 msgstr ""
 
-#: part/templates/part/category.html:23 part/views.py:1976
+#: part/templates/part/category.html:24 part/views.py:2043
 msgid "Create new part category"
 msgstr ""
 
-#: part/templates/part/category.html:27
+#: part/templates/part/category.html:30
 msgid "Edit part category"
 msgstr ""
 
-#: part/templates/part/category.html:30
+#: part/templates/part/category.html:35
 msgid "Delete part category"
 msgstr ""
 
-#: part/templates/part/category.html:39 part/templates/part/category.html:78
+#: part/templates/part/category.html:45 part/templates/part/category.html:84
 msgid "Category Details"
 msgstr ""
 
-#: part/templates/part/category.html:44
+#: part/templates/part/category.html:50
 msgid "Category Path"
 msgstr ""
 
-#: part/templates/part/category.html:49
+#: part/templates/part/category.html:55
 msgid "Category Description"
 msgstr ""
 
-#: part/templates/part/category.html:62 part/templates/part/detail.html:64
+#: part/templates/part/category.html:68 part/templates/part/detail.html:64
 msgid "Keywords"
 msgstr ""
 
-#: part/templates/part/category.html:68
+#: part/templates/part/category.html:74
 msgid "Subcategories"
 msgstr ""
 
-#: part/templates/part/category.html:73
+#: part/templates/part/category.html:79
 msgid "Parts (Including subcategories)"
 msgstr ""
 
-#: part/templates/part/category.html:106
+#: part/templates/part/category.html:112
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:107 part/views.py:491
+#: part/templates/part/category.html:114 part/views.py:511
 msgid "Create new part"
 msgstr ""
 
-#: part/templates/part/category.html:111
+#: part/templates/part/category.html:120
 msgid "Set category"
 msgstr ""
 
-#: part/templates/part/category.html:111
+#: part/templates/part/category.html:120
 msgid "Set Category"
 msgstr ""
 
-#: part/templates/part/category.html:113
+#: part/templates/part/category.html:123
 msgid "Export Data"
 msgstr ""
 
-#: part/templates/part/category.html:162
+#: part/templates/part/category.html:172
 msgid "Create new location"
 msgstr ""
 
-#: part/templates/part/category.html:167 part/templates/part/category.html:196
+#: part/templates/part/category.html:177 part/templates/part/category.html:207
 msgid "New Category"
 msgstr ""
 
-#: part/templates/part/category.html:168
+#: part/templates/part/category.html:178
 msgid "Create new category"
 msgstr ""
 
-#: part/templates/part/category.html:197
+#: part/templates/part/category.html:208
 msgid "Create new Part Category"
 msgstr ""
 
-#: part/templates/part/category.html:203 stock/views.py:1314
+#: part/templates/part/category.html:214 stock/views.py:1314
 msgid "Create new Stock Location"
 msgstr ""
 
@@ -2461,7 +2469,7 @@ msgstr ""
 msgid "Part Details"
 msgstr ""
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:85
+#: part/templates/part/detail.html:25 part/templates/part/part_base.html:95
 #: templates/js/part.html:112
 msgid "IPN"
 msgstr ""
@@ -2491,7 +2499,7 @@ msgstr ""
 msgid "Default Supplier"
 msgstr ""
 
-#: part/templates/part/detail.html:102 part/templates/part/params.html:22
+#: part/templates/part/detail.html:102 part/templates/part/params.html:24
 msgid "Units"
 msgstr ""
 
@@ -2616,24 +2624,25 @@ msgstr ""
 msgid "Part Parameters"
 msgstr ""
 
-#: part/templates/part/params.html:13
+#: part/templates/part/params.html:14
 msgid "Add new parameter"
 msgstr ""
 
-#: part/templates/part/params.html:13 templates/InvenTree/settings/part.html:12
+#: part/templates/part/params.html:14 templates/InvenTree/settings/part.html:12
 msgid "New Parameter"
 msgstr ""
 
-#: part/templates/part/params.html:21 stock/models.py:1391
+#: part/templates/part/params.html:23 stock/models.py:1391
 #: templates/js/stock.html:112
 msgid "Value"
 msgstr ""
 
-#: part/templates/part/params.html:33
+#: part/templates/part/params.html:36
 msgid "Edit"
 msgstr ""
 
-#: part/templates/part/params.html:34 part/templates/part/supplier.html:17
+#: part/templates/part/params.html:39 part/templates/part/supplier.html:17
+#: users/models.py:141
 msgid "Delete"
 msgstr ""
 
@@ -2684,39 +2693,43 @@ msgstr ""
 msgid "Show pricing information"
 msgstr ""
 
-#: part/templates/part/part_base.html:70
-msgid "Part actions"
-msgstr ""
-
-#: part/templates/part/part_base.html:72
-msgid "Duplicate part"
-msgstr ""
-
-#: part/templates/part/part_base.html:73
-msgid "Edit part"
+#: part/templates/part/part_base.html:60
+msgid "Count part stock"
 msgstr ""
 
 #: part/templates/part/part_base.html:75
+msgid "Part actions"
+msgstr ""
+
+#: part/templates/part/part_base.html:78
+msgid "Duplicate part"
+msgstr ""
+
+#: part/templates/part/part_base.html:81
+msgid "Edit part"
+msgstr ""
+
+#: part/templates/part/part_base.html:84
 msgid "Delete part"
 msgstr ""
 
-#: part/templates/part/part_base.html:114 templates/js/table_filters.html:65
+#: part/templates/part/part_base.html:124 templates/js/table_filters.html:65
 msgid "In Stock"
 msgstr ""
 
-#: part/templates/part/part_base.html:121
+#: part/templates/part/part_base.html:131
 msgid "Allocated to Build Orders"
 msgstr ""
 
-#: part/templates/part/part_base.html:128
+#: part/templates/part/part_base.html:138
 msgid "Allocated to Sales Orders"
 msgstr ""
 
-#: part/templates/part/part_base.html:150
+#: part/templates/part/part_base.html:160
 msgid "Can Build"
 msgstr ""
 
-#: part/templates/part/part_base.html:156
+#: part/templates/part/part_base.html:166
 msgid "Underway"
 msgstr ""
 
@@ -2736,7 +2749,7 @@ msgstr ""
 msgid "Upload new image"
 msgstr ""
 
-#: part/templates/part/sale_prices.html:9 part/templates/part/tabs.html:50
+#: part/templates/part/sale_prices.html:9 part/templates/part/tabs.html:53
 msgid "Sale Price"
 msgstr ""
 
@@ -2797,11 +2810,11 @@ msgstr ""
 msgid "BOM"
 msgstr ""
 
-#: part/templates/part/tabs.html:34
+#: part/templates/part/tabs.html:37
 msgid "Used In"
 msgstr ""
 
-#: part/templates/part/tabs.html:58 stock/templates/stock/item_base.html:282
+#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:282
 msgid "Tests"
 msgstr ""
 
@@ -2829,176 +2842,176 @@ msgstr ""
 msgid "New Variant"
 msgstr ""
 
-#: part/views.py:76
+#: part/views.py:78
 msgid "Add part attachment"
 msgstr ""
 
-#: part/views.py:125 templates/attachment_table.html:30
+#: part/views.py:129 templates/attachment_table.html:30
 msgid "Edit attachment"
 msgstr ""
 
-#: part/views.py:129
+#: part/views.py:135
 msgid "Part attachment updated"
 msgstr ""
 
-#: part/views.py:144
+#: part/views.py:150
 msgid "Delete Part Attachment"
 msgstr ""
 
-#: part/views.py:150
+#: part/views.py:158
 msgid "Deleted part attachment"
 msgstr ""
 
-#: part/views.py:159
+#: part/views.py:167
 msgid "Create Test Template"
 msgstr ""
 
-#: part/views.py:186
+#: part/views.py:196
 msgid "Edit Test Template"
 msgstr ""
 
-#: part/views.py:200
+#: part/views.py:212
 msgid "Delete Test Template"
 msgstr ""
 
-#: part/views.py:207
+#: part/views.py:221
 msgid "Set Part Category"
 msgstr ""
 
-#: part/views.py:255
+#: part/views.py:271
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr ""
 
-#: part/views.py:290
+#: part/views.py:306
 msgid "Create Variant"
 msgstr ""
 
-#: part/views.py:368
+#: part/views.py:386
 msgid "Duplicate Part"
 msgstr ""
 
-#: part/views.py:373
+#: part/views.py:393
 msgid "Copied part"
 msgstr ""
 
-#: part/views.py:496
+#: part/views.py:518
 msgid "Created new part"
 msgstr ""
 
-#: part/views.py:707
+#: part/views.py:733
 msgid "Part QR Code"
 msgstr ""
 
-#: part/views.py:724
+#: part/views.py:752
 msgid "Upload Part Image"
 msgstr ""
 
-#: part/views.py:729 part/views.py:764
+#: part/views.py:760 part/views.py:797
 msgid "Updated part image"
 msgstr ""
 
-#: part/views.py:738
+#: part/views.py:769
 msgid "Select Part Image"
 msgstr ""
 
-#: part/views.py:767
+#: part/views.py:800
 msgid "Part image not found"
 msgstr ""
 
-#: part/views.py:778
+#: part/views.py:811
 msgid "Edit Part Properties"
 msgstr ""
 
-#: part/views.py:800
+#: part/views.py:835
 msgid "Validate BOM"
 msgstr ""
 
-#: part/views.py:963
+#: part/views.py:1002
 msgid "No BOM file provided"
 msgstr ""
 
-#: part/views.py:1313
+#: part/views.py:1352
 msgid "Enter a valid quantity"
 msgstr ""
 
-#: part/views.py:1338 part/views.py:1341
+#: part/views.py:1377 part/views.py:1380
 msgid "Select valid part"
 msgstr ""
 
-#: part/views.py:1347
+#: part/views.py:1386
 msgid "Duplicate part selected"
 msgstr ""
 
-#: part/views.py:1385
+#: part/views.py:1424
 msgid "Select a part"
 msgstr ""
 
-#: part/views.py:1391
+#: part/views.py:1430
 msgid "Selected part creates a circular BOM"
 msgstr ""
 
-#: part/views.py:1395
+#: part/views.py:1434
 msgid "Specify quantity"
 msgstr ""
 
-#: part/views.py:1645
+#: part/views.py:1690
 msgid "Confirm Part Deletion"
 msgstr ""
 
-#: part/views.py:1652
+#: part/views.py:1699
 msgid "Part was deleted"
 msgstr ""
 
-#: part/views.py:1661
+#: part/views.py:1708
 msgid "Part Pricing"
 msgstr ""
 
-#: part/views.py:1783
+#: part/views.py:1834
 msgid "Create Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1791
+#: part/views.py:1844
 msgid "Edit Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1798
+#: part/views.py:1853
 msgid "Delete Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1806
+#: part/views.py:1863
 msgid "Create Part Parameter"
 msgstr ""
 
-#: part/views.py:1856
+#: part/views.py:1915
 msgid "Edit Part Parameter"
 msgstr ""
 
-#: part/views.py:1870
+#: part/views.py:1931
 msgid "Delete Part Parameter"
 msgstr ""
 
-#: part/views.py:1927
+#: part/views.py:1990
 msgid "Edit Part Category"
 msgstr ""
 
-#: part/views.py:1962
+#: part/views.py:2027
 msgid "Delete Part Category"
 msgstr ""
 
-#: part/views.py:1968
+#: part/views.py:2035
 msgid "Part category was deleted"
 msgstr ""
 
-#: part/views.py:2027
+#: part/views.py:2098
 msgid "Create BOM item"
 msgstr ""
 
-#: part/views.py:2093
+#: part/views.py:2166
 msgid "Edit BOM item"
 msgstr ""
 
-#: part/views.py:2141
+#: part/views.py:2216
 msgid "Confim BOM item deletion"
 msgstr ""
 
@@ -3371,15 +3384,15 @@ msgid "Stock adjustment actions"
 msgstr ""
 
 #: stock/templates/stock/item_base.html:98
-#: stock/templates/stock/location.html:38 templates/stock_table.html:15
+#: stock/templates/stock/location.html:38 templates/stock_table.html:19
 msgid "Count stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:99 templates/stock_table.html:13
+#: stock/templates/stock/item_base.html:99 templates/stock_table.html:17
 msgid "Add stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:100 templates/stock_table.html:14
+#: stock/templates/stock/item_base.html:100 templates/stock_table.html:18
 msgid "Remove stock"
 msgstr ""
 
@@ -3819,6 +3832,14 @@ msgstr ""
 msgid "Add Stock Tracking Entry"
 msgstr ""
 
+#: templates/403.html:5 templates/403.html:11
+msgid "Permission Denied"
+msgstr ""
+
+#: templates/403.html:14
+msgid "You do not have permission to view this page."
+msgstr ""
+
 #: templates/InvenTree/bom_invalid.html:7
 msgid "BOM Waiting Validation"
 msgstr ""
@@ -3827,6 +3848,10 @@ msgstr ""
 msgid "Pending Builds"
 msgstr ""
 
+#: templates/InvenTree/index.html:4
+msgid "Index"
+msgstr ""
+
 #: templates/InvenTree/latest_parts.html:7
 msgid "Latest Parts"
 msgstr ""
@@ -4392,39 +4417,39 @@ msgstr ""
 msgid "Purchasable"
 msgstr ""
 
-#: templates/navbar.html:22
+#: templates/navbar.html:29
 msgid "Buy"
 msgstr ""
 
-#: templates/navbar.html:30
+#: templates/navbar.html:39
 msgid "Sell"
 msgstr ""
 
-#: templates/navbar.html:40
+#: templates/navbar.html:50
 msgid "Scan Barcode"
 msgstr ""
 
-#: templates/navbar.html:49
+#: templates/navbar.html:59 users/models.py:27
 msgid "Admin"
 msgstr ""
 
-#: templates/navbar.html:52
+#: templates/navbar.html:62
 msgid "Settings"
 msgstr ""
 
-#: templates/navbar.html:53
+#: templates/navbar.html:63
 msgid "Logout"
 msgstr ""
 
-#: templates/navbar.html:55
+#: templates/navbar.html:65
 msgid "Login"
 msgstr ""
 
-#: templates/navbar.html:58
+#: templates/navbar.html:68
 msgid "About InvenTree"
 msgstr ""
 
-#: templates/navbar.html:59
+#: templates/navbar.html:69
 msgid "Statistics"
 msgstr ""
 
@@ -4436,38 +4461,94 @@ msgstr ""
 msgid "Export Stock Information"
 msgstr ""
 
-#: templates/stock_table.html:13
+#: templates/stock_table.html:17
 msgid "Add to selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:14
+#: templates/stock_table.html:18
 msgid "Remove from selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:15
+#: templates/stock_table.html:19
 msgid "Stocktake selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:20
 msgid "Move selected stock items"
 msgstr ""
 
-#: templates/stock_table.html:16
+#: templates/stock_table.html:20
 msgid "Move stock"
 msgstr ""
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:21
 msgid "Order selected items"
 msgstr ""
 
-#: templates/stock_table.html:17
+#: templates/stock_table.html:21
 msgid "Order stock"
 msgstr ""
 
-#: templates/stock_table.html:18
+#: templates/stock_table.html:24
 msgid "Delete selected items"
 msgstr ""
 
-#: templates/stock_table.html:18
+#: templates/stock_table.html:24
 msgid "Delete Stock"
 msgstr ""
+
+#: users/admin.py:62
+msgid "Users"
+msgstr ""
+
+#: users/admin.py:63
+msgid "Select which users are assigned to this group"
+msgstr ""
+
+#: users/admin.py:124
+msgid "Personal info"
+msgstr ""
+
+#: users/admin.py:125
+msgid "Permissions"
+msgstr ""
+
+#: users/admin.py:128
+msgid "Important dates"
+msgstr ""
+
+#: users/models.py:124
+msgid "Permission set"
+msgstr ""
+
+#: users/models.py:132
+msgid "Group"
+msgstr ""
+
+#: users/models.py:135
+msgid "View"
+msgstr ""
+
+#: users/models.py:135
+msgid "Permission to view items"
+msgstr ""
+
+#: users/models.py:137
+msgid "Create"
+msgstr ""
+
+#: users/models.py:137
+msgid "Permission to add items"
+msgstr ""
+
+#: users/models.py:139
+msgid "Update"
+msgstr ""
+
+#: users/models.py:139
+msgid "Permissions to edit items"
+msgstr ""
+
+#: users/models.py:141
+msgid "Permission to delete items"
+msgstr ""

From 16d720b62c08b5e22289d019f4ea27d949e26bca Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 00:36:55 +1100
Subject: [PATCH 57/77] Update permission requirements for API

- Automatically use model permissions by default!
-
---
 InvenTree/InvenTree/settings.py |  4 ++++
 InvenTree/build/api.py          | 14 +-----------
 InvenTree/company/api.py        | 19 +----------------
 InvenTree/order/api.py          | 28 +-----------------------
 InvenTree/part/api.py           | 38 ---------------------------------
 InvenTree/stock/api.py          | 15 -------------
 6 files changed, 7 insertions(+), 111 deletions(-)

diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 21b8a0ead1..75f6f58993 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -231,6 +231,10 @@ REST_FRAMEWORK = {
         'rest_framework.authentication.SessionAuthentication',
         'rest_framework.authentication.TokenAuthentication',
     ),
+    'DEFAULT_PERMISSION_CLASSES': (
+        'rest_framework.permissions.IsAuthenticated',
+        'rest_framework.permissions.DjangoModelPermissions',
+    ),
     'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema'
 }
 
diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index c0faee6c15..d4e458c506 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -7,7 +7,7 @@ from __future__ import unicode_literals
 
 from django_filters.rest_framework import DjangoFilterBackend
 from rest_framework import filters
-from rest_framework import generics, permissions
+from rest_framework import generics
 
 from django.conf.urls import url, include
 
@@ -28,10 +28,6 @@ class BuildList(generics.ListCreateAPIView):
     queryset = Build.objects.all()
     serializer_class = BuildSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -99,10 +95,6 @@ class BuildDetail(generics.RetrieveUpdateAPIView):
     queryset = Build.objects.all()
     serializer_class = BuildSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
 
 class BuildItemList(generics.ListCreateAPIView):
     """ API endpoint for accessing a list of BuildItem objects
@@ -137,10 +129,6 @@ class BuildItemList(generics.ListCreateAPIView):
 
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
     ]
diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py
index 0e54d7d7fb..548ac96016 100644
--- a/InvenTree/company/api.py
+++ b/InvenTree/company/api.py
@@ -7,7 +7,7 @@ from __future__ import unicode_literals
 
 from django_filters.rest_framework import DjangoFilterBackend
 from rest_framework import filters
-from rest_framework import generics, permissions
+from rest_framework import generics
 
 from django.conf.urls import url, include
 from django.db.models import Q
@@ -40,10 +40,6 @@ class CompanyList(generics.ListCreateAPIView):
 
         return queryset
     
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -82,10 +78,6 @@ class CompanyDetail(generics.RetrieveUpdateDestroyAPIView):
 
         return queryset
     
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
 
 class SupplierPartList(generics.ListCreateAPIView):
     """ API endpoint for list view of SupplierPart object
@@ -170,10 +162,6 @@ class SupplierPartList(generics.ListCreateAPIView):
 
     serializer_class = SupplierPartSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -202,7 +190,6 @@ class SupplierPartDetail(generics.RetrieveUpdateDestroyAPIView):
 
     queryset = SupplierPart.objects.all()
     serializer_class = SupplierPartSerializer
-    permission_classes = (permissions.IsAuthenticated,)
 
     read_only_fields = [
     ]
@@ -218,10 +205,6 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
     queryset = SupplierPriceBreak.objects.all()
     serializer_class = SupplierPriceBreakSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
     ]
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index a7915878c5..4a9dbfa2ac 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -6,7 +6,7 @@ JSON API for the Order app
 from __future__ import unicode_literals
 
 from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework import generics, permissions
+from rest_framework import generics
 from rest_framework import filters
 
 from django.conf.urls import url, include
@@ -109,10 +109,6 @@ class POList(generics.ListCreateAPIView):
 
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -162,10 +158,6 @@ class PODetail(generics.RetrieveUpdateAPIView):
 
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated
-    ]
-
 
 class POLineItemList(generics.ListCreateAPIView):
     """ API endpoint for accessing a list of POLineItem objects
@@ -188,10 +180,6 @@ class POLineItemList(generics.ListCreateAPIView):
 
         return self.serializer_class(*args, **kwargs)
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
     ]
@@ -208,10 +196,6 @@ class POLineItemDetail(generics.RetrieveUpdateAPIView):
     queryset = PurchaseOrderLineItem
     serializer_class = POLineItemSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
 
 class SOAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
     """
@@ -300,10 +284,6 @@ class SOList(generics.ListCreateAPIView):
 
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -351,8 +331,6 @@ class SODetail(generics.RetrieveUpdateAPIView):
 
         return queryset
 
-    permission_classes = [permissions.IsAuthenticated]
-
 
 class SOLineItemList(generics.ListCreateAPIView):
     """
@@ -398,8 +376,6 @@ class SOLineItemList(generics.ListCreateAPIView):
 
         return queryset
 
-    permission_classes = [permissions.IsAuthenticated]
-
     filter_backends = [DjangoFilterBackend]
 
     filter_fields = [
@@ -414,8 +390,6 @@ class SOLineItemDetail(generics.RetrieveUpdateAPIView):
     queryset = SalesOrderLineItem.objects.all()
     serializer_class = SOLineItemSerializer
 
-    permission_classes = [permissions.IsAuthenticated]
-
 
 class POAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
     """
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 6834503466..d4aeec5bd9 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -55,10 +55,6 @@ class CategoryList(generics.ListCreateAPIView):
     queryset = PartCategory.objects.all()
     serializer_class = part_serializers.CategorySerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     def get_queryset(self):
         """
         Custom filtering:
@@ -119,10 +115,6 @@ class PartSalePriceList(generics.ListCreateAPIView):
     queryset = PartSellPriceBreak.objects.all()
     serializer_class = part_serializers.PartSalePriceSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend
     ]
@@ -182,8 +174,6 @@ class PartTestTemplateList(generics.ListCreateAPIView):
 
         return queryset
 
-    permission_classes = [permissions.IsAuthenticated]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.OrderingFilter,
@@ -221,10 +211,6 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView):
     queryset = Part.objects.all()
     serializer_class = part_serializers.PartThumbSerializerUpdate
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend
     ]
@@ -246,10 +232,6 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
 
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     def get_serializer(self, *args, **kwargs):
 
         try:
@@ -580,10 +562,6 @@ class PartList(generics.ListCreateAPIView):
 
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -676,10 +654,6 @@ class PartParameterTemplateList(generics.ListCreateAPIView):
     queryset = PartParameterTemplate.objects.all()
     serializer_class = part_serializers.PartParameterTemplateSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         filters.OrderingFilter,
     ]
@@ -699,10 +673,6 @@ class PartParameterList(generics.ListCreateAPIView):
     queryset = PartParameter.objects.all()
     serializer_class = part_serializers.PartParameterSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend
     ]
@@ -796,10 +766,6 @@ class BomList(generics.ListCreateAPIView):
 
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -816,10 +782,6 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
     queryset = BomItem.objects.all()
     serializer_class = part_serializers.BomItemSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
 
 class BomItemValidate(generics.UpdateAPIView):
     """ API endpoint for validating a BomItem """
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 55bc62a44e..790de7d879 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -68,7 +68,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
 
     queryset = StockItem.objects.all()
     serializer_class = StockItemSerializer
-    permission_classes = (permissions.IsAuthenticated,)
 
     def get_queryset(self, *args, **kwargs):
 
@@ -289,10 +288,6 @@ class StockLocationList(generics.ListCreateAPIView):
             
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -695,10 +690,6 @@ class StockList(generics.ListCreateAPIView):
 
         return queryset
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -744,10 +735,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
     queryset = StockItemTestResult.objects.all()
     serializer_class = StockItemTestResultSerializer
 
-    permission_classes = [
-        permissions.IsAuthenticated,
-    ]
-
     filter_backends = [
         DjangoFilterBackend,
         filters.SearchFilter,
@@ -799,7 +786,6 @@ class StockTrackingList(generics.ListCreateAPIView):
 
     queryset = StockItemTracking.objects.all()
     serializer_class = StockTrackingSerializer
-    permission_classes = [permissions.IsAuthenticated]
 
     def get_serializer(self, *args, **kwargs):
         try:
@@ -871,7 +857,6 @@ class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
 
     queryset = StockLocation.objects.all()
     serializer_class = LocationSerializer
-    permission_classes = (permissions.IsAuthenticated,)
 
 
 stock_endpoints = [

From 3f59ce3f93030c37afe37e2f69a547f927ddd70d Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 01:30:36 +1100
Subject: [PATCH 58/77] Update unit tests

- requires the user to actually have the necessary permissions!
---
 InvenTree/InvenTree/api.py     |  2 ++
 InvenTree/InvenTree/helpers.py | 20 +++++++++++++++++++
 InvenTree/company/test_api.py  | 13 ++++++++++++-
 InvenTree/part/test_api.py     | 29 +++++++++++++++++++++++++++-
 InvenTree/part/test_views.py   | 35 ++++++++++++++++++++++++++++++++--
 InvenTree/part/views.py        | 12 ++++++------
 InvenTree/stock/test_api.py    | 16 ++++++++++++++++
 7 files changed, 117 insertions(+), 10 deletions(-)

diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py
index 44e7ec383f..1bdfb79ceb 100644
--- a/InvenTree/InvenTree/api.py
+++ b/InvenTree/InvenTree/api.py
@@ -30,6 +30,8 @@ class InfoView(AjaxView):
     Use to confirm that the server is running, etc.
     """
 
+    permission_classes = [permissions.AllowAny]
+
     def get(self, request, *args, **kwargs):
 
         data = {
diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index 9b470902b1..13b770539c 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -15,6 +15,8 @@ from django.http import StreamingHttpResponse
 from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext as _
 
+from django.contrib.auth.models import Permission
+
 import InvenTree.version
 
 from .settings import MEDIA_URL, STATIC_URL
@@ -441,3 +443,21 @@ def validateFilterString(value):
         results[k] = v
 
     return results
+
+
+def addUserPermission(user, permission):
+    """
+    Shortcut function for adding a certain permission to a user.
+    """
+    
+    perm = Permission.objects.get(codename=permission)
+    user.user_permissions.add(perm)
+
+
+def addUserPermissions(user, permissions):
+    """
+    Shortcut function for adding multiple permissions to a user.
+    """
+
+    for permission in permissions:
+        addUserPermission(user, permission)
diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py
index bf4cc6643e..643608542d 100644
--- a/InvenTree/company/test_api.py
+++ b/InvenTree/company/test_api.py
@@ -3,6 +3,8 @@ from rest_framework import status
 from django.urls import reverse
 from django.contrib.auth import get_user_model
 
+from InvenTree.helpers import addUserPermissions
+
 from .models import Company
 
 
@@ -14,7 +16,16 @@ class CompanyTest(APITestCase):
     def setUp(self):
         # Create a user for auth
         User = get_user_model()
-        User.objects.create_user('testuser', 'test@testing.com', 'password')
+        self.user = User.objects.create_user('testuser', 'test@testing.com', 'password')
+        
+        perms = [
+            'view_company',
+            'change_company',
+            'add_company',
+        ]
+
+        addUserPermissions(self.user, perms)
+        
         self.client.login(username='testuser', password='password')
 
         Company.objects.create(name='ACME', description='Supplier', is_customer=False, is_supplier=True)
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index 3b116fa445..9fdc2688cb 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -9,6 +9,7 @@ from stock.models import StockItem
 from company.models import Company
 
 from InvenTree.status_codes import StockStatus
+from InvenTree.helpers import addUserPermissions
 
 
 class PartAPITest(APITestCase):
@@ -29,7 +30,33 @@ class PartAPITest(APITestCase):
     def setUp(self):
         # Create a user for auth
         User = get_user_model()
-        User.objects.create_user('testuser', 'test@testing.com', 'password')
+        self.user = User.objects.create_user(
+            username='testuser',
+            email='test@testing.com',
+            password='password'
+        )
+
+        # Add the permissions required to access the API endpoints
+        perms = [
+            'view_part',
+            'add_part',
+            'change_part',
+            'delete_part',
+            'view_partcategory',
+            'add_partcategory',
+            'change_partcategory',
+            'view_bomitem',
+            'add_bomitem',
+            'change_bomitem',
+            'view_partattachment',
+            'change_partattachment',
+            'add_partattachment',
+            'view_parttesttemplate',
+            'add_parttesttemplate',
+            'change_parttesttemplate',
+        ]
+
+        addUserPermissions(self.user, perms)
 
         self.client.login(username='testuser', password='password')
 
diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index bc09784a47..b1ae991a0c 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -4,6 +4,8 @@ from django.test import TestCase
 from django.urls import reverse
 from django.contrib.auth import get_user_model
 
+from InvenTree.helpers import addUserPermissions
+
 from .models import Part
 
 
@@ -23,7 +25,32 @@ class PartViewTestCase(TestCase):
 
         # Create a user
         User = get_user_model()
-        User.objects.create_user('username', 'user@email.com', 'password')
+        self.user = User.objects.create_user(
+            username='username',
+            email='user@email.com',
+            password='password'
+        )
+
+        # Add the permissions required to access the pages
+        perms = [
+            'view_part',
+            'add_part',
+            'change_part',
+            'delete_part',
+            'view_partcategory',
+            'add_partcategory',
+            'change_partcategory',
+            'view_bomitem',
+            'add_bomitem',
+            'change_bomitem',
+            'view_partattachment',
+            'change_partattachment',
+            'add_partattachment',
+        ]
+
+        addUserPermissions(self.user, perms)
+
+        self.user.save()
 
         self.client.login(username='username', password='password')
 
@@ -140,12 +167,14 @@ class PartTests(PartViewTestCase):
     """ Tests for Part forms """
 
     def test_part_edit(self):
+
         response = self.client.get(reverse('part-edit', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
 
         keys = response.context.keys()
         data = str(response.content)
 
+        self.assertEqual(response.status_code, 200)
+
         self.assertIn('part', keys)
         self.assertIn('csrf_token', keys)
 
@@ -189,6 +218,8 @@ class PartAttachmentTests(PartViewTestCase):
         response = self.client.get(reverse('part-attachment-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
         self.assertEqual(response.status_code, 200)
 
+        # TODO - Create a new attachment using this view
+
     def test_invalid_create(self):
         """ test creation of an attachment for an invalid part """
 
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 47a77078be..8e0980c35e 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -635,7 +635,7 @@ class PartNotes(UpdateView):
     template_name = 'part/notes.html'
     model = Part
 
-    permission_required = 'part.update_part'
+    permission_required = 'part.change_part'
 
     fields = ['notes']
 
@@ -753,7 +753,7 @@ class PartImageUpload(AjaxUpdateView):
 
     form_class = part_forms.PartImageForm
 
-    permission_required = 'part.update_part'
+    permission_required = 'part.change_part'
 
     def get_data(self):
         return {
@@ -768,7 +768,7 @@ class PartImageSelect(AjaxUpdateView):
     ajax_template_name = 'part/select_image.html'
     ajax_form_title = _('Select Part Image')
 
-    permission_required = 'part.update_part'
+    permission_required = 'part.change_part'
 
     fields = [
         'image',
@@ -811,7 +811,7 @@ class PartEdit(AjaxUpdateView):
     ajax_form_title = _('Edit Part Properties')
     context_object_name = 'part'
 
-    permission_required = 'part.update_part'
+    permission_required = 'part.change_part'
 
     def get_form(self):
         """ Create form for Part editing.
@@ -837,7 +837,7 @@ class BomValidate(AjaxUpdateView):
     context_object_name = 'part'
     form_class = part_forms.BomValidateForm
 
-    permission_required = ('part.update_part')
+    permission_required = ('part.change_part')
 
     def get_context(self):
         return {
@@ -905,7 +905,7 @@ class BomUpload(PermissionRequiredMixin, FormView):
     missing_columns = []
     allowed_parts = []
 
-    permission_required = ('part.update_part', 'part.add_bomitem')
+    permission_required = ('part.change_part', 'part.add_bomitem')
 
     def get_success_url(self):
         part = self.get_object()
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index a522bc5415..8348a3e331 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -3,6 +3,8 @@ from rest_framework import status
 from django.urls import reverse
 from django.contrib.auth import get_user_model
 
+from InvenTree.helpers import addUserPermissions
+
 from .models import StockLocation
 
 
@@ -22,6 +24,20 @@ class StockAPITestCase(APITestCase):
         # Create a user for auth
         User = get_user_model()
         self.user = User.objects.create_user('testuser', 'test@testing.com', 'password')
+
+        # Add the necessary permissions to the user
+        perms = [
+            'view_stockitemtestresult',
+            'change_stockitemtestresult',
+            'add_stockitemtestresult',
+            'add_stocklocation',
+            'change_stocklocation',
+            'add_stockitem',
+            'change_stockitem',
+        ]
+
+        addUserPermissions(self.user, perms)
+
         self.client.login(username='testuser', password='password')
 
     def doPost(self, url, data={}):

From c910307ce5aeabf6f79eba77c661e819fffc1bfc Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Mon, 5 Oct 2020 10:04:54 -0500
Subject: [PATCH 59/77] Only saving Group model rulesets on instance creation
 and when inlines are saved

---
 InvenTree/users/admin.py  |  8 ++------
 InvenTree/users/models.py | 16 +++++++++++++---
 2 files changed, 15 insertions(+), 9 deletions(-)

diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index 86d4bb1a86..765bc0d795 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -97,15 +97,11 @@ class RoleGroupAdmin(admin.ModelAdmin):
     # Save inlines before model
     # https://stackoverflow.com/a/14860703/12794913
     def save_model(self, request, obj, form, change):
-        if obj is not None:
-            # Save model immediately only if in 'Add role' view
-            super().save_model(request, obj, form, change)
-        else:
-            pass  # don't actually save the parent instance
+        pass  # don't actually save the parent instance
 
     def save_formset(self, request, form, formset, change):
         formset.save()  # this will save the children
-        form.instance.save()  # form.instance is the parent
+        form.instance.save(update_fields=['name'])  # form.instance is the parent
 
 
 class InvenTreeUserAdmin(UserAdmin):
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 5fe86e15fa..990d3770c0 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -155,8 +155,15 @@ class RuleSet(models.Model):
             model=model
         )
 
-    def __str__(self):
-        return self.name
+    def __str__(self, debug=False):
+        """ Ruleset string representation """
+        if debug:
+            # Makes debugging easier
+            return f'{str(self.group).ljust(15)}: {self.name.title().ljust(15)} | ' \
+                   f'v: {str(self.can_view).ljust(5)} | a: {str(self.can_add).ljust(5)} | ' \
+                   f'c: {str(self.can_change).ljust(5)} | d: {str(self.can_delete).ljust(5)}'
+        else:
+            return self.name
 
     def save(self, *args, **kwargs):
 
@@ -327,5 +334,8 @@ def create_missing_rule_sets(sender, instance, **kwargs):
     then we can now use these RuleSet values to update the
     group permissions.
     """
+    created = kwargs.get('created', False)
+    update_fields = kwargs.get('update_fields', None)
 
-    update_group_roles(instance)
+    if created or update_fields:
+        update_group_roles(instance)

From d980da72472a430219988be89fabcfd50993535f Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Mon, 5 Oct 2020 10:52:47 -0500
Subject: [PATCH 60/77] Fixed permission assign test unit

---
 InvenTree/users/admin.py  | 1 +
 InvenTree/users/models.py | 1 +
 InvenTree/users/tests.py  | 6 ++++--
 3 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/InvenTree/users/admin.py b/InvenTree/users/admin.py
index 765bc0d795..e4974a3f7a 100644
--- a/InvenTree/users/admin.py
+++ b/InvenTree/users/admin.py
@@ -101,6 +101,7 @@ class RoleGroupAdmin(admin.ModelAdmin):
 
     def save_formset(self, request, form, formset, change):
         formset.save()  # this will save the children
+        # update_fields is required to trigger permissions update
         form.instance.save(update_fields=['name'])  # form.instance is the parent
 
 
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 990d3770c0..df8e91d293 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -335,6 +335,7 @@ def create_missing_rule_sets(sender, instance, **kwargs):
     group permissions.
     """
     created = kwargs.get('created', False)
+    # To trigger the group permissions update: update_fields should not be None
     update_fields = kwargs.get('update_fields', None)
 
     if created or update_fields:
diff --git a/InvenTree/users/tests.py b/InvenTree/users/tests.py
index d14ffc4950..e277422f71 100644
--- a/InvenTree/users/tests.py
+++ b/InvenTree/users/tests.py
@@ -137,7 +137,8 @@ class RuleSetModelTest(TestCase):
 
             rule.save()
 
-        group.save()
+        # update_fields is required to trigger permissions update
+        group.save(update_fields=['name'])
 
         # There should now be three permissions for each rule set
         self.assertEqual(group.permissions.count(), 3 * len(permission_set))
@@ -151,7 +152,8 @@ class RuleSetModelTest(TestCase):
 
             rule.save()
 
-        group.save()
+        # update_fields is required to trigger permissions update
+        group.save(update_fields=['name'])
 
         # There should now not be any permissions assigned to this group
         self.assertEqual(group.permissions.count(), 0)

From 8b2189dacad374b39478a1452a9a72d186a1b840 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 09:27:22 +1100
Subject: [PATCH 61/77] Add global context 'roles'

- Access via template e.g. {% if roles.part.view %}
- Always True if the user is a superuser
---
 InvenTree/InvenTree/context.py  | 33 +++++++++++++++++++++++++++++++++
 InvenTree/InvenTree/settings.py |  1 +
 2 files changed, 34 insertions(+)

diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py
index 7de41eef15..71aee5c2c5 100644
--- a/InvenTree/InvenTree/context.py
+++ b/InvenTree/InvenTree/context.py
@@ -17,3 +17,36 @@ def status_codes(request):
         'BuildStatus': BuildStatus,
         'StockStatus': StockStatus,
     }
+
+
+def user_roles(request):
+    """
+    Return a map of the current roles assigned to the user.
+    
+    Roles are denoted by their simple names, and then the permission type.
+
+    Permissions can be access as follows:
+
+    - roles.part.view
+    - roles.build.delete
+
+    Each value will return a boolean True / False
+    """
+
+    user = request.user
+
+    roles = {}
+
+    for group in user.groups.all():
+        for rule in group.rule_sets.all():
+            roles[rule.name] = {
+                'view': rule.can_view or user.is_superuser,
+                'add': rule.can_add or user.is_superuser,
+                'change': rule.can_change or user.is_superuser,
+                'delete': rule.can_delete or user.is_superuser,
+            }
+
+    print("Roles:")
+    print(roles)
+
+    return {'roles': roles}
diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index 75f6f58993..c6f8b40069 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -210,6 +210,7 @@ TEMPLATES = [
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
                 'InvenTree.context.status_codes',
+                'InvenTree.context.user_roles',
             ],
         },
     },

From 556ffa1099197a0f41d734dbdcc4782e2e40b5ee Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 09:28:05 +1100
Subject: [PATCH 62/77] Change label for permissions to match django permission
 names

---
 .../migrations/0003_auto_20201005_2227.py     | 23 +++++++++++++++++++
 InvenTree/users/models.py                     |  4 ++--
 2 files changed, 25 insertions(+), 2 deletions(-)
 create mode 100644 InvenTree/users/migrations/0003_auto_20201005_2227.py

diff --git a/InvenTree/users/migrations/0003_auto_20201005_2227.py b/InvenTree/users/migrations/0003_auto_20201005_2227.py
new file mode 100644
index 0000000000..92d7e341fa
--- /dev/null
+++ b/InvenTree/users/migrations/0003_auto_20201005_2227.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.7 on 2020-10-05 22:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0002_auto_20201004_0158'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='ruleset',
+            name='can_add',
+            field=models.BooleanField(default=False, help_text='Permission to add items', verbose_name='Add'),
+        ),
+        migrations.AlterField(
+            model_name='ruleset',
+            name='can_change',
+            field=models.BooleanField(default=False, help_text='Permissions to edit items', verbose_name='Change'),
+        ),
+    ]
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 5fe86e15fa..3bd976f0a4 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -134,9 +134,9 @@ class RuleSet(models.Model):
 
     can_view = models.BooleanField(verbose_name=_('View'), default=True, help_text=_('Permission to view items'))
 
-    can_add = models.BooleanField(verbose_name=_('Create'), default=False, help_text=_('Permission to add items'))
+    can_add = models.BooleanField(verbose_name=_('Add'), default=False, help_text=_('Permission to add items'))
 
-    can_change = models.BooleanField(verbose_name=_('Update'), default=False, help_text=_('Permissions to edit items'))
+    can_change = models.BooleanField(verbose_name=_('Change'), default=False, help_text=_('Permissions to edit items'))
 
     can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items'))
     

From 23aee234f0002b35f93945994562ccb3037d5d92 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 09:32:05 +1100
Subject: [PATCH 63/77] Change index page to use roles rather than perms to
 determine user permissions

---
 InvenTree/templates/InvenTree/index.html | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index b20d61116d..8e59d51d2b 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -9,24 +9,24 @@ InvenTree | {% trans "Index" %}
 <hr>
 
 <div class='col-sm-6'>
-    {% if perms.part.view_part %}
+    {% if roles.part.view %}
     {% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
     {% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
     {% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
     {% endif %}
-    {% if perms.build.view_build %}
+    {% if roles.build.view %}
     {% include "InvenTree/build_pending.html" with collapse_id="build_pending" %}
     {% endif %}
 </div>
 <div class='col-sm-6'>
-    {% if perms.stock.view_stockitem %}
+    {% if roles.stock.view %}
     {% include "InvenTree/low_stock.html" with collapse_id="order" %}
     {% include "InvenTree/required_stock_build.html" with collapse_id="stock_to_build" %}
     {% endif %}
-    {% if perms.order.view_purchaseorder %}
+    {% if roles.purchase_order.view %}
     {% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
     {% endif %}
-    {% if perms.order.view_salesorder %}
+    {% if roles.sales_order.view %}
     {% include "InvenTree/so_outstanding.html" with collapse_id="so_outstanding" %}
     {% endif %}
 </div>

From fa21d66c411aadf71031ba415b02ea12e64fd3cf Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 09:54:37 +1100
Subject: [PATCH 64/77] Fix logic for global context object 'roles'

- User may be a part of multiple groups
- Roles are additive across groups
---
 InvenTree/InvenTree/context.py | 25 ++++++++++++++++---------
 1 file changed, 16 insertions(+), 9 deletions(-)

diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py
index 71aee5c2c5..f9d856f566 100644
--- a/InvenTree/InvenTree/context.py
+++ b/InvenTree/InvenTree/context.py
@@ -35,18 +35,25 @@ def user_roles(request):
 
     user = request.user
 
-    roles = {}
+    roles = {
+    }
 
     for group in user.groups.all():
         for rule in group.rule_sets.all():
-            roles[rule.name] = {
-                'view': rule.can_view or user.is_superuser,
-                'add': rule.can_add or user.is_superuser,
-                'change': rule.can_change or user.is_superuser,
-                'delete': rule.can_delete or user.is_superuser,
-            }
 
-    print("Roles:")
-    print(roles)
+            # Ensure the role name is in the dict
+            if rule.name not in roles:
+                roles[rule.name] = {
+                    'view': user.is_superuser,
+                    'add': user.is_superuser,
+                    'change': user.is_superuser,
+                    'delete': user.is_superuser
+                }
+
+            # Roles are additive across groups
+            roles[rule.name]['view'] |= rule.can_view
+            roles[rule.name]['add'] |= rule.can_add
+            roles[rule.name]['change'] |= rule.can_change
+            roles[rule.name]['delete'] |= rule.can_delete
 
     return {'roles': roles}

From d2e2e7511f70e90c2e6f3787bc66bc5ea65a9df3 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 10:07:39 +1100
Subject: [PATCH 65/77] Update templates: Change perms to roles

---
 InvenTree/part/templates/part/bom.html       |  2 +-
 InvenTree/part/templates/part/build.html     |  2 +-
 InvenTree/part/templates/part/category.html  | 14 +++++++-------
 InvenTree/part/templates/part/notes.html     |  2 +-
 InvenTree/part/templates/part/params.html    |  8 ++++----
 InvenTree/part/templates/part/part_base.html | 16 ++++++++--------
 InvenTree/part/templates/part/tabs.html      |  2 +-
 InvenTree/templates/navbar.html              | 10 +++++-----
 InvenTree/templates/stock_table.html         |  8 ++++----
 9 files changed, 32 insertions(+), 32 deletions(-)

diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html
index 610e60847a..c996edc2db 100644
--- a/InvenTree/part/templates/part/bom.html
+++ b/InvenTree/part/templates/part/bom.html
@@ -39,7 +39,7 @@
     <button class='btn btn-default action-button' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'><span class='fas fa-plus-circle'></span></button>
     <button class='btn btn-default action-button' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'><span class='fas fa-check-circle'></span></button>
     {% elif part.active %}
-    {% if perms.part.change_part %}
+    {% if roles.part.change %}
     <button class='btn btn-default action-button' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'><span class='fas fa-edit'></span></button>
     {% if part.is_bom_valid == False %}
     <button class='btn btn-default action-button' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'><span class='fas fa-clipboard-check'></span></button>
diff --git a/InvenTree/part/templates/part/build.html b/InvenTree/part/templates/part/build.html
index ad51d33ab2..bfd72a2f70 100644
--- a/InvenTree/part/templates/part/build.html
+++ b/InvenTree/part/templates/part/build.html
@@ -10,7 +10,7 @@
 <div id='button-toolbar'>
     <div class='button-toolbar container-flui' style='float: right';>
         {% if part.active %}
-        {% if perms.build.add_build %}
+        {% if roles.build.add %}
         <button class="btn btn-success" id='start-build'>{% trans "Start New Build" %}</button>
         {% endif %}
         {% endif %}
diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 25417a1d9b..d73aba0291 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -9,7 +9,7 @@
         {% if category %}
         <h3>
             {{ category.name }}
-            {% if user.is_staff and perms.part.change_partcategory %}
+            {% if user.is_staff and roles.part.change %}
             <a href="{% url 'admin:part_partcategory_change' category.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
             {% endif %}
         </h3>
@@ -20,18 +20,18 @@
         {% endif %}
         <p>
             <div class='btn-group action-buttons'>
-                {% if perms.part.add_partcategory %}
+                {% if roles.part.add %}
                 <button class='btn btn-default' id='cat-create' title='{% trans "Create new part category" %}'>
                     <span class='fas fa-plus-circle icon-green'/>
                 </button>
                 {% endif %}
                 {% if category %}
-                {% if perms.part.change_partcategory %}
+                {% if roles.part.change %}
                 <button class='btn btn-default' id='cat-edit' title='{% trans "Edit part category" %}'>
                     <span class='fas fa-edit icon-blue'/>
                 </button>
                 {% endif %}
-                {% if perms.part.delete_partcategory %}
+                {% if roles.part.delete %}
                 <button class='btn btn-default' id='cat-delete' title='{% trans "Delete part category" %}'>
                     <span class='fas fa-trash-alt icon-red'/>
                 </button>
@@ -110,13 +110,13 @@
     <div class='button-toolbar container-fluid' style="float: right;">
         <div class='btn-group'>
             <button class='btn btn-default' id='part-export' title='{% trans "Export Part Data" %}'>{% trans "Export" %}</button>
-            {% if perms.part.add_part %}
+            {% if roles.part.add %}
             <button class='btn btn-success' id='part-create' title='{% trans "Create new part" %}'>{% trans "New Part" %}</button>
             {% endif %}
             <div class='btn-group'>
                 <button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
                 <ul class='dropdown-menu'>
-                    {% if perms.part.change_part %}
+                    {% if roles.part.change %}
                     <li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
                     {% endif %}
                     <li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
@@ -190,7 +190,7 @@
         location.href = url;
     });
 
-    {% if perms.part.add_part %}
+    {% if roles.part.add %}
     $("#part-create").click(function() {
         launchModalForm(
             "{% url 'part-create' %}",
diff --git a/InvenTree/part/templates/part/notes.html b/InvenTree/part/templates/part/notes.html
index 68711c881e..3f833325cd 100644
--- a/InvenTree/part/templates/part/notes.html
+++ b/InvenTree/part/templates/part/notes.html
@@ -29,7 +29,7 @@
         <h4>{% trans "Part Notes" %}</h4>
     </div>
     <div class='col-sm-6'>
-        {% if perms.part.change_part %}
+        {% if roles.part.change %}
         <button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
         {% endif %}
     </div>
diff --git a/InvenTree/part/templates/part/params.html b/InvenTree/part/templates/part/params.html
index 9984830242..ba8aa0566d 100644
--- a/InvenTree/part/templates/part/params.html
+++ b/InvenTree/part/templates/part/params.html
@@ -10,7 +10,7 @@
 
 <div id='button-toolbar'>
     <div class='button-toolbar container-fluid' style='float: right;'>
-        {% if perms.part.add_partparameter %}
+        {% if roles.part.add %}
         <button title='{% trans "Add new parameter" %}' class='btn btn-success' id='param-create'>{% trans "New Parameter" %}</button>
         {% endif %}
     </div>
@@ -32,10 +32,10 @@
             <td>
                 {{ param.template.units }}
                 <div class='btn-group' style='float: right;'>
-                    {% if perms.part.change_partparameter %}
+                    {% if roles.part.change %}
                     <button title='{% trans "Edit" %}' class='btn btn-default btn-glyph param-edit' url="{% url 'part-param-edit' param.id %}" type='button'><span class='fas fa-edit'/></button>
                     {% endif %}
-                    {% if perms.part.delete_partparameter %}
+                    {% if roles.part.delete %}
                     <button title='{% trans "Delete" %}' class='btn btn-default btn-glyph param-delete' url="{% url 'part-param-delete' param.id %}" type='button'><span class='fas fa-trash-alt icon-red'/></button>
                     {% endif %}
                 </div>
@@ -54,7 +54,7 @@
     $('#param-table').inventreeTable({
     });
 
-    {% if perms.part.add_partparameter %}
+    {% if roles.part.add %}
     $('#param-create').click(function() {
         launchModalForm("{% url 'part-param-create' %}?part={{ part.id }}", {
             reload: true,
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 6103cb5369..750f3f3806 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -56,13 +56,13 @@
             <button type='button' class='btn btn-default' id='price-button' title='{% trans "Show pricing information" %}'>
                 <span id='part-price-icon' class='fas fa-dollar-sign'/>
             </button>
-            {% if perms.stock.change_stockitem %}
+            {% if roles.stock.change %}
             <button type='button' class='btn btn-default' id='part-count' title='{% trans "Count part stock" %}'>
                 <span class='fas fa-clipboard-list'/>
             </button>
             {% endif %}
             {% if part.purchaseable %}
-            {% if perms.order.add_purchaseorder %}
+            {% if roles.purchase_order.add %}
             <button type='button' class='btn btn-default' id='part-order' title='{% trans "Order part" %}'>
                 <span id='part-order-icon' class='fas fa-shopping-cart'/>
             </button>
@@ -70,17 +70,17 @@
             {% endif %}
             {% endif %}
             <!-- Part actions -->
-            {% if perms.part.add_part or perms.part.change_part or perms.part.delete_part %}
+            {% if roles.part.add or roles.part.change or roles.part.delete %}
             <div class='btn-group'>
                 <button id='part-actions' title='{% trans "Part actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'> <span class='fas fa-shapes'></span> <span class='caret'></span></button>
                 <ul class='dropdown-menu'>
-                    {% if perms.part.add_part %}
+                    {% if roles.part.add %}
                     <li><a href='#' id='part-duplicate'><span class='fas fa-copy'></span> {% trans "Duplicate part" %}</a></li>
                     {% endif %}
-                    {% if perms.part.change_part %}
+                    {% if roles.part.change %}
                     <li><a href='#' id='part-edit'><span class='fas fa-edit icon-blue'></span> {% trans "Edit part" %}</a></li>
                     {% endif %}
-                    {% if not part.active and perms.part.delete_part %}
+                    {% if not part.active and roles.part.delete %}
                     <li><a href='#' id='part-delete'><span class='fas fa-trash-alt icon-red'></span> {% trans "Delete part" %}</a></li>
                     {% endif %}
                 </ul>
@@ -284,7 +284,7 @@
                         });
     });
 
-    {% if perms.part.change_part %}
+    {% if roles.part.change %}
     $("#part-edit").click(function() {
         launchModalForm(
             "{% url 'part-edit' part.id %}",
@@ -304,7 +304,7 @@
         });
     });
 
-    {% if perms.part.add_part %}
+    {% if roles.part.add %}
     $("#part-duplicate").click(function() {
         launchModalForm(
             "{% url 'part-duplicate' part.id %}",
diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html
index 02d8672979..e36675eeab 100644
--- a/InvenTree/part/templates/part/tabs.html
+++ b/InvenTree/part/templates/part/tabs.html
@@ -26,7 +26,7 @@
     {% if part.assembly %}
     <li{% ifequal tab 'bom' %} class="active"{% endifequal %}>
         <a href="{% url 'part-bom' part.id %}">{% trans "BOM" %}<span class="badge{% if part.is_bom_valid == False %} badge-alert{% endif %}">{{ part.bom_count }}</span></a></li>
-    {% if perms.build.view_build %}
+    {% if roles.build.view %}
     <li{% ifequal tab 'build' %} class="active"{% endifequal %}>
         <a href="{% url 'part-build' part.id %}">{% trans "Build Orders" %}<span class='badge'>{{ part.builds.count }}</span></a>
     </li>
diff --git a/InvenTree/templates/navbar.html b/InvenTree/templates/navbar.html
index 2224d85bc4..148a96c583 100644
--- a/InvenTree/templates/navbar.html
+++ b/InvenTree/templates/navbar.html
@@ -15,16 +15,16 @@
     </div>
     <div class="navbar-collapse collapse">
       <ul class="nav navbar-nav">
-        {% if perms.part.view_part or perms.part.view_partcategory %}
+        {% if roles.part.view %}
         <li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
         {% endif %}
-        {% if perms.stock.view_stockitem or perms.part.view_stocklocation %}
+        {% if roles.stock.view %}
         <li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
         {% endif %}
-        {% if perms.build.view_build %}
+        {% if roles.build.view %}
         <li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
         {% endif %}
-        {% if perms.order.view_purchaseorder %}
+        {% if roles.purchase_order.view %}
         <li class='nav navbar-nav'>
           <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
           <ul class='dropdown-menu'>
@@ -34,7 +34,7 @@
           </ul>
         </li>
         {% endif %}
-        {% if perms.order.view_salesorder %}
+        {% if roles.sales_order.view %}
         <li class='nav navbar-nav'>
           <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
           <ul class='dropdown-menu'>
diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html
index be26b1a976..b4b1b9d50f 100644
--- a/InvenTree/templates/stock_table.html
+++ b/InvenTree/templates/stock_table.html
@@ -6,21 +6,21 @@
             <button class='btn btn-default' id='stock-export' title='{% trans "Export Stock Information" %}'>{% trans "Export" %}</button>
             {% if read_only %}
             {% else %}
-            {% if perms.stock.add_stockitem %}
+            {% if roles.stock.add %}
             <button class="btn btn-success" id='item-create'>{% trans "New Stock Item" %}</button>
             {% endif %}
-            {% if perms.stock.change_stockitem or perms.stock.delete_stockitem %}
+            {% if roles.stock.change or roles.stock.delete %}
             <div class="btn-group">
                 <button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
                 <ul class="dropdown-menu">
-                    {% if perms.stock.change_stockitem %}
+                    {% if roles.stock.change %}
                     <li><a href="#" id='multi-item-add' title='{% trans "Add to selected stock items" %}'>{% trans "Add stock" %}</a></li>
                     <li><a href="#" id='multi-item-remove' title='{% trans "Remove from selected stock items" %}'>{% trans "Remove stock" %}</a></li>
                     <li><a href="#" id='multi-item-stocktake' title='{% trans "Stocktake selected stock items" %}'>{% trans "Count stock" %}</a></li>
                     <li><a href='#' id='multi-item-move' title='{% trans "Move selected stock items" %}'>{% trans "Move stock" %}</a></li>
                     <li><a href='#' id='multi-item-order' title='{% trans "Order selected items" %}'>{% trans "Order stock" %}</a></li>
                     {% endif %}
-                    {% if perms.stock.delete_stockitem %}
+                    {% if roles.stock.delete %}
                     <li><a href='#' id='multi-item-delete' title='{% trans "Delete selected items" %}'>{% trans "Delete Stock" %}</a></li>
                     {% endif %}
                 </ul>

From dc2c9aa662e9a4af344ed60da0db84045da1e3a0 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 11:29:38 +1100
Subject: [PATCH 66/77] Add InvenTreeRoleMixin

- Simplifies permission requirements for views
- e.g. 'part.view' rather than 'part.view_partcategory'
---
 InvenTree/InvenTree/views.py | 54 ++++++++++++++++++++++++++++++++++++
 InvenTree/part/api.py        |  4 +++
 InvenTree/part/views.py      | 25 +++++++++--------
 InvenTree/stock/api.py       |  4 +++
 InvenTree/users/models.py    | 32 +++++++++++++++++++++
 5 files changed, 108 insertions(+), 11 deletions(-)

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index d940229ebe..9cd4aeb514 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -22,6 +22,7 @@ from django.views.generic.base import TemplateView
 from part.models import Part, PartCategory
 from stock.models import StockLocation, StockItem
 from common.models import InvenTreeSetting, ColorTheme
+from users.models import check_user_role, RuleSet
 
 from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
 from .helpers import str2bool
@@ -682,3 +683,56 @@ class DatabaseStatsView(AjaxView):
         """
 
         return ctx
+
+
+class InvenTreeRoleMixin(PermissionRequiredMixin):
+    """
+    Permission class based on user roles, not user 'permissions'.
+
+    To specify which role is required for the mixin,
+    set the class attribute 'role_required' to something like the following:
+
+    role_required = 'part.add'
+    role_required = [
+        'part.change',
+        'build.add',
+    ]
+    """
+
+    # By default, no roles are required
+    # Roles must be specified 
+    role_required = None
+
+    def has_permission(self):
+        """
+        Determine if the current user 
+        """
+
+        roles_required = []
+
+        if type(self.role_required) is str:
+            roles_required.append(self.role_required)
+        elif type(self.role_required) in [list, tuple]:
+            roles_required = self.role_required
+
+        # List of permissions that will be required
+        permissions = []
+
+        user = self.request.user
+
+        # Superuser can have any permissions they desire
+        if user.is_superuser:
+            return True
+
+        print(type(self), "Required roles:", roles_required)
+
+        for required in roles_required:
+
+            (role, permission) = required.split('.')
+            
+            # Return False if the user does not have *any* of the required roles
+            if not check_user_role(user, role, permission):
+                return False
+
+        # We did not fail any required checks
+        return True
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index d4aeec5bd9..d643b8671a 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -44,6 +44,10 @@ class PartCategoryTree(TreeSerializer):
     def get_items(self):
         return PartCategory.objects.all().prefetch_related('parts', 'children')
 
+    permission_classes = [
+        permissions.IsAuthenticated,
+    ]
+
 
 class CategoryList(generics.ListCreateAPIView):
     """ API endpoint for accessing a list of PartCategory objects.
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 8e0980c35e..e352cddb08 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -12,7 +12,6 @@ from django.shortcuts import HttpResponseRedirect
 from django.utils.translation import gettext_lazy as _
 from django.urls import reverse, reverse_lazy
 from django.views.generic import DetailView, ListView, FormView, UpdateView
-from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.forms.models import model_to_dict
 from django.forms import HiddenInput, CheckboxInput
 from django.conf import settings
@@ -39,17 +38,21 @@ from .admin import PartResource
 
 from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
 from InvenTree.views import QRCodeView
+from InvenTree.views import InvenTreeRoleMixin
 
 from InvenTree.helpers import DownloadFile, str2bool
 
 
-class PartIndex(PermissionRequiredMixin, ListView):
+class PartIndex(InvenTreeRoleMixin, ListView):
     """ View for displaying list of Part objects
     """
+
     model = Part
     template_name = 'part/category.html'
     context_object_name = 'parts'
-    permission_required = ('part.view_part', 'part.view_partcategory')
+
+    role_required = 'part.view'
+
 
     def get_queryset(self):
         return Part.objects.all().select_related('category')
@@ -658,7 +661,7 @@ class PartNotes(UpdateView):
         return ctx
 
 
-class PartDetail(PermissionRequiredMixin, DetailView):
+class PartDetail(InvenTreeRoleMixin, DetailView):
     """ Detail view for Part object
     """
 
@@ -666,7 +669,7 @@ class PartDetail(PermissionRequiredMixin, DetailView):
     queryset = Part.objects.all().select_related('category')
     template_name = 'part/detail.html'
 
-    permission_required = 'part.view_part'
+    role_required = 'part.view'
 
     # Add in some extra context information based on query params
     def get_context_data(self, **kwargs):
@@ -869,7 +872,7 @@ class BomValidate(AjaxUpdateView):
         return self.renderJsonResponse(request, form, data, context=self.get_context())
 
 
-class BomUpload(PermissionRequiredMixin, FormView):
+class BomUpload(InvenTreeRoleMixin, FormView):
     """ View for uploading a BOM file, and handling BOM data importing.
 
     The BOM upload process is as follows:
@@ -905,7 +908,7 @@ class BomUpload(PermissionRequiredMixin, FormView):
     missing_columns = []
     allowed_parts = []
 
-    permission_required = ('part.change_part', 'part.add_bomitem')
+    role_required = ('part.change', 'part.add')
 
     def get_success_url(self):
         part = self.get_object()
@@ -1931,7 +1934,7 @@ class PartParameterDelete(AjaxDeleteView):
     ajax_form_title = _('Delete Part Parameter')
     
 
-class CategoryDetail(PermissionRequiredMixin, DetailView):
+class CategoryDetail(InvenTreeRoleMixin, DetailView):
     """ Detail view for PartCategory """
 
     model = PartCategory
@@ -1939,7 +1942,7 @@ class CategoryDetail(PermissionRequiredMixin, DetailView):
     queryset = PartCategory.objects.all().prefetch_related('children')
     template_name = 'part/category_partlist.html'
 
-    permission_required = 'part.view_partcategory'
+    role_required = 'part.view'
 
     def get_context_data(self, **kwargs):
 
@@ -2081,13 +2084,13 @@ class CategoryCreate(AjaxCreateView):
         return initials
 
 
-class BomItemDetail(PermissionRequiredMixin, DetailView):
+class BomItemDetail(InvenTreeRoleMixin, DetailView):
     """ Detail view for BomItem """
     context_object_name = 'item'
     queryset = BomItem.objects.all()
     template_name = 'part/bom-detail.html'
 
-    permission_required = 'part.view_bomitem'
+    role_required = 'part.view'
 
 
 class BomItemCreate(AjaxCreateView):
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 790de7d879..ba802b75d9 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -52,6 +52,10 @@ class StockCategoryTree(TreeSerializer):
     def get_items(self):
         return StockLocation.objects.all().prefetch_related('stock_items', 'children')
 
+    permission_classes = [
+        permissions.IsAuthenticated,
+    ]
+
 
 class StockDetail(generics.RetrieveUpdateDestroyAPIView):
     """ API detail endpoint for Stock object
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 3bd976f0a4..24b0318695 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -329,3 +329,35 @@ def create_missing_rule_sets(sender, instance, **kwargs):
     """
 
     update_group_roles(instance)
+
+
+def check_user_role(user, role, permission):
+    """
+    Check if a user has a particular role:permission combination.
+
+    If the user is a superuser, this will return True
+    """
+
+    if user.is_superuser:
+        return True
+
+    for group in user.groups.all():
+
+        for rule in group.rule_sets.all():
+
+            if rule.name == role:
+
+                if permission == 'add' and rule.can_add:
+                    return True
+
+                if permission == 'change' and rule.can_change:
+                    return True
+
+                if permission == 'view' and rule.can_view:
+                    return True
+
+                if permission == 'delete' and rule.can_delete:
+                    return True
+
+    # No matching permissions found
+    return False

From c740cce5e435523e800e15dcb73433c1673b2f46 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 11:31:04 +1100
Subject: [PATCH 67/77] PEP fixes

---
 InvenTree/InvenTree/views.py | 9 +++------
 InvenTree/part/views.py      | 1 -
 2 files changed, 3 insertions(+), 7 deletions(-)

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 9cd4aeb514..ca8f6c646c 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -22,7 +22,7 @@ from django.views.generic.base import TemplateView
 from part.models import Part, PartCategory
 from stock.models import StockLocation, StockItem
 from common.models import InvenTreeSetting, ColorTheme
-from users.models import check_user_role, RuleSet
+from users.models import check_user_role
 
 from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
 from .helpers import str2bool
@@ -700,12 +700,12 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
     """
 
     # By default, no roles are required
-    # Roles must be specified 
+    # Roles must be specified
     role_required = None
 
     def has_permission(self):
         """
-        Determine if the current user 
+        Determine if the current user
         """
 
         roles_required = []
@@ -715,9 +715,6 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
         elif type(self.role_required) in [list, tuple]:
             roles_required = self.role_required
 
-        # List of permissions that will be required
-        permissions = []
-
         user = self.request.user
 
         # Superuser can have any permissions they desire
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index e352cddb08..09390bf8c0 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -53,7 +53,6 @@ class PartIndex(InvenTreeRoleMixin, ListView):
 
     role_required = 'part.view'
 
-
     def get_queryset(self):
         return Part.objects.all().select_related('category')
 

From 11d31960c7c64b78b6ced94fe662d611119596a9 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 11:42:16 +1100
Subject: [PATCH 68/77] Change modal form permissions to use new "role"
 strategy

---
 InvenTree/InvenTree/views.py | 119 +++++++++++++++--------------------
 InvenTree/part/views.py      |  74 +++++++++++-----------
 2 files changed, 89 insertions(+), 104 deletions(-)

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index ca8f6c646c..198903db9a 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -108,31 +108,66 @@ class TreeSerializer(views.APIView):
         return JsonResponse(response, safe=False)
 
 
-class AjaxMixin(PermissionRequiredMixin):
+class InvenTreeRoleMixin(PermissionRequiredMixin):
+    """
+    Permission class based on user roles, not user 'permissions'.
+
+    To specify which role is required for the mixin,
+    set the class attribute 'role_required' to something like the following:
+
+    role_required = 'part.add'
+    role_required = [
+        'part.change',
+        'build.add',
+    ]
+    """
+
+    # By default, no roles are required
+    # Roles must be specified
+    role_required = None
+
+    def has_permission(self):
+        """
+        Determine if the current user
+        """
+
+        roles_required = []
+
+        if type(self.role_required) is str:
+            roles_required.append(self.role_required)
+        elif type(self.role_required) in [list, tuple]:
+            roles_required = self.role_required
+
+        user = self.request.user
+
+        # Superuser can have any permissions they desire
+        if user.is_superuser:
+            return True
+
+        for required in roles_required:
+
+            (role, permission) = required.split('.')
+            
+            # Return False if the user does not have *any* of the required roles
+            if not check_user_role(user, role, permission):
+                return False
+
+        # We did not fail any required checks
+        return True
+
+
+class AjaxMixin(InvenTreeRoleMixin):
     """ AjaxMixin provides basic functionality for rendering a Django form to JSON.
     Handles jsonResponse rendering, and adds extra data for the modal forms to process
     on the client side.
 
     Any view which inherits the AjaxMixin will need
-    correct permissions set using the 'permission_required' attribute
+    correct permissions set using the 'role_required' attribute
 
     """
 
-    # By default, allow *any* permissions
-    permission_required = '*'
-
-    def has_permission(self):
-        """
-        Override the default behaviour of has_permission from PermissionRequiredMixin.
-
-        Basically, if permission_required attribute = '*',
-        no permissions are actually required!
-        """
-
-        if self.permission_required == '*':
-            return True
-        else:
-            return super().has_permission()
+    # By default, allow *any* role
+    role_required = None
 
     # By default, point to the modal_form template
     # (this can be overridden by a child class)
@@ -683,53 +718,3 @@ class DatabaseStatsView(AjaxView):
         """
 
         return ctx
-
-
-class InvenTreeRoleMixin(PermissionRequiredMixin):
-    """
-    Permission class based on user roles, not user 'permissions'.
-
-    To specify which role is required for the mixin,
-    set the class attribute 'role_required' to something like the following:
-
-    role_required = 'part.add'
-    role_required = [
-        'part.change',
-        'build.add',
-    ]
-    """
-
-    # By default, no roles are required
-    # Roles must be specified
-    role_required = None
-
-    def has_permission(self):
-        """
-        Determine if the current user
-        """
-
-        roles_required = []
-
-        if type(self.role_required) is str:
-            roles_required.append(self.role_required)
-        elif type(self.role_required) in [list, tuple]:
-            roles_required = self.role_required
-
-        user = self.request.user
-
-        # Superuser can have any permissions they desire
-        if user.is_superuser:
-            return True
-
-        print(type(self), "Required roles:", roles_required)
-
-        for required in roles_required:
-
-            (role, permission) = required.split('.')
-            
-            # Return False if the user does not have *any* of the required roles
-            if not check_user_role(user, role, permission):
-                return False
-
-        # We did not fail any required checks
-        return True
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 09390bf8c0..6498774285 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -80,7 +80,7 @@ class PartAttachmentCreate(AjaxCreateView):
     ajax_form_title = _("Add part attachment")
     ajax_template_name = "modal_form.html"
 
-    permission_required = 'part.add_partattachment'
+    role_required = 'part.add'
 
     def post_save(self):
         """ Record the user that uploaded the attachment """
@@ -130,7 +130,7 @@ class PartAttachmentEdit(AjaxUpdateView):
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Edit attachment')
 
-    permission_required = 'part.change_partattachment'
+    role_required = 'part.change'
     
     def get_data(self):
         return {
@@ -153,7 +153,7 @@ class PartAttachmentDelete(AjaxDeleteView):
     ajax_template_name = "attachment_delete.html"
     context_object_name = "attachment"
 
-    permission_required = 'part.delete_partattachment'
+    role_required = 'part.delete'
 
     def get_data(self):
         return {
@@ -168,7 +168,7 @@ class PartTestTemplateCreate(AjaxCreateView):
     form_class = part_forms.EditPartTestTemplateForm
     ajax_form_title = _("Create Test Template")
 
-    permission_required = 'part.add_parttesttemplate'
+    role_required = 'part.add'
     
     def get_initial(self):
 
@@ -197,7 +197,7 @@ class PartTestTemplateEdit(AjaxUpdateView):
     form_class = part_forms.EditPartTestTemplateForm
     ajax_form_title = _("Edit Test Template")
 
-    permission_required = 'part.change_parttesttemplate'
+    role_required = 'part.change'
 
     def get_form(self):
 
@@ -213,7 +213,7 @@ class PartTestTemplateDelete(AjaxDeleteView):
     model = PartTestTemplate
     ajax_form_title = _("Delete Test Template")
 
-    permission_required = 'part.delete_parttesttemplate'
+    role_required = 'part.delete'
 
 
 class PartSetCategory(AjaxUpdateView):
@@ -223,7 +223,7 @@ class PartSetCategory(AjaxUpdateView):
     ajax_form_title = _('Set Part Category')
     form_class = part_forms.SetPartCategoryForm
 
-    permission_required = 'part.change_part'
+    role_required = 'part.change'
 
     category = None
     parts = []
@@ -308,7 +308,7 @@ class MakePartVariant(AjaxCreateView):
     ajax_form_title = _('Create Variant')
     ajax_template_name = 'part/variant_part.html'
 
-    permission_required = 'part.add_part'
+    role_required = 'part.add'
 
     def get_part_template(self):
         return get_object_or_404(Part, id=self.kwargs['pk'])
@@ -388,7 +388,7 @@ class PartDuplicate(AjaxCreateView):
     ajax_form_title = _("Duplicate Part")
     ajax_template_name = "part/copy_part.html"
 
-    permission_required = 'part.add_part'
+    role_required = 'part.add'
 
     def get_data(self):
         return {
@@ -513,7 +513,7 @@ class PartCreate(AjaxCreateView):
     ajax_form_title = _('Create new part')
     ajax_template_name = 'part/create_part.html'
 
-    permission_required = 'part.add_part'
+    role_required = 'part.add'
 
     def get_data(self):
         return {
@@ -637,7 +637,7 @@ class PartNotes(UpdateView):
     template_name = 'part/notes.html'
     model = Part
 
-    permission_required = 'part.change_part'
+    role_required = 'part.change'
 
     fields = ['notes']
 
@@ -734,7 +734,7 @@ class PartQRCode(QRCodeView):
 
     ajax_form_title = _("Part QR Code")
 
-    permission_required = 'part.view_part'
+    role_required = 'part.view'
 
     def get_qr_data(self):
         """ Generate QR code data for the Part """
@@ -755,7 +755,7 @@ class PartImageUpload(AjaxUpdateView):
 
     form_class = part_forms.PartImageForm
 
-    permission_required = 'part.change_part'
+    role_required = 'part.change'
 
     def get_data(self):
         return {
@@ -770,7 +770,7 @@ class PartImageSelect(AjaxUpdateView):
     ajax_template_name = 'part/select_image.html'
     ajax_form_title = _('Select Part Image')
 
-    permission_required = 'part.change_part'
+    role_required = 'part.change'
 
     fields = [
         'image',
@@ -813,7 +813,7 @@ class PartEdit(AjaxUpdateView):
     ajax_form_title = _('Edit Part Properties')
     context_object_name = 'part'
 
-    permission_required = 'part.change_part'
+    role_required = 'part.change'
 
     def get_form(self):
         """ Create form for Part editing.
@@ -839,7 +839,7 @@ class BomValidate(AjaxUpdateView):
     context_object_name = 'part'
     form_class = part_forms.BomValidateForm
 
-    permission_required = ('part.change_part')
+    role_required = 'part.change'
 
     def get_context(self):
         return {
@@ -1507,7 +1507,7 @@ class BomUpload(InvenTreeRoleMixin, FormView):
 class PartExport(AjaxView):
     """ Export a CSV file containing information on multiple parts """
 
-    permission_required = 'part.view_part'
+    role_required = 'part.view'
 
     def get_parts(self, request):
         """ Extract part list from the POST parameters.
@@ -1586,7 +1586,7 @@ class BomDownload(AjaxView):
     - File format should be passed as a query param e.g. ?format=csv
     """
 
-    permission_required = ('part.view_part', 'part.view_bomitem')
+    role_required = 'part.view'
 
     model = Part
 
@@ -1641,7 +1641,7 @@ class BomExport(AjaxView):
     form_class = part_forms.BomExportForm
     ajax_form_title = _("Export Bill of Materials")
 
-    permission_required = ('part.view_part', 'part.view_bomitem')
+    role_required = 'part.view'
 
     def get(self, request, *args, **kwargs):
         return self.renderJsonResponse(request, self.form_class())
@@ -1692,7 +1692,7 @@ class PartDelete(AjaxDeleteView):
     ajax_form_title = _('Confirm Part Deletion')
     context_object_name = 'part'
 
-    permission_required = 'part.delete_part'
+    role_required = 'part.delete'
 
     success_url = '/part/'
 
@@ -1710,8 +1710,8 @@ class PartPricing(AjaxView):
     ajax_form_title = _("Part Pricing")
     form_class = part_forms.PartPriceForm
 
-    permission_required = ('company.view_supplierpricebreak', 'part.view_part')
-
+    role_required = ['sales_order.view', 'part.view']
+    
     def get_part(self):
         try:
             return Part.objects.get(id=self.kwargs['pk'])
@@ -1829,7 +1829,7 @@ class PartPricing(AjaxView):
 class PartParameterTemplateCreate(AjaxCreateView):
     """ View for creating a new PartParameterTemplate """
 
-    permission_required = 'part.add_partparametertemplate'
+    role_required = 'part.add'
 
     model = PartParameterTemplate
     form_class = part_forms.EditPartParameterTemplateForm
@@ -1839,7 +1839,7 @@ class PartParameterTemplateCreate(AjaxCreateView):
 class PartParameterTemplateEdit(AjaxUpdateView):
     """ View for editing a PartParameterTemplate """
 
-    permission_required = 'part.change_partparametertemplate'
+    role_required = 'part.change'
 
     model = PartParameterTemplate
     form_class = part_forms.EditPartParameterTemplateForm
@@ -1849,7 +1849,7 @@ class PartParameterTemplateEdit(AjaxUpdateView):
 class PartParameterTemplateDelete(AjaxDeleteView):
     """ View for deleting an existing PartParameterTemplate """
 
-    permission_required = 'part.delete_partparametertemplate'
+    role_required = 'part.delete'
 
     model = PartParameterTemplate
     ajax_form_title = _("Delete Part Parameter Template")
@@ -1858,7 +1858,7 @@ class PartParameterTemplateDelete(AjaxDeleteView):
 class PartParameterCreate(AjaxCreateView):
     """ View for creating a new PartParameter """
 
-    permission_required = 'part.add_partparameter'
+    role_required = 'part.add'
 
     model = PartParameter
     form_class = part_forms.EditPartParameterForm
@@ -1910,7 +1910,7 @@ class PartParameterCreate(AjaxCreateView):
 class PartParameterEdit(AjaxUpdateView):
     """ View for editing a PartParameter """
 
-    permission_required = 'part.change_partparameter'
+    role_required = 'part.change'
 
     model = PartParameter
     form_class = part_forms.EditPartParameterForm
@@ -1926,7 +1926,7 @@ class PartParameterEdit(AjaxUpdateView):
 class PartParameterDelete(AjaxDeleteView):
     """ View for deleting a PartParameter """
 
-    permission_required = 'part.delete_partparameter'
+    role_required = 'part.delete'
 
     model = PartParameter
     ajax_template_name = 'part/param_delete.html'
@@ -1991,7 +1991,7 @@ class CategoryEdit(AjaxUpdateView):
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Edit Part Category')
 
-    permission_required = 'part.change_partcategory'
+    role_required = 'part.change'
 
     def get_context_data(self, **kwargs):
         context = super(CategoryEdit, self).get_context_data(**kwargs).copy()
@@ -2030,7 +2030,7 @@ class CategoryDelete(AjaxDeleteView):
     context_object_name = 'category'
     success_url = '/part/'
 
-    permission_required = 'part.delete_partcategory'
+    role_required = 'part.delete'
 
     def get_data(self):
         return {
@@ -2046,7 +2046,7 @@ class CategoryCreate(AjaxCreateView):
     ajax_template_name = 'modal_form.html'
     form_class = part_forms.EditCategoryForm
 
-    permission_required = 'part.add_partcategory'
+    role_required = 'part.add'
 
     def get_context_data(self, **kwargs):
         """ Add extra context data to template.
@@ -2099,7 +2099,7 @@ class BomItemCreate(AjaxCreateView):
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Create BOM item')
 
-    permission_required = 'part.add_bomitem'
+    role_required = 'part.add'
 
     def get_form(self):
         """ Override get_form() method to reduce Part selection options.
@@ -2167,7 +2167,7 @@ class BomItemEdit(AjaxUpdateView):
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Edit BOM item')
 
-    permission_required = 'part.change_bomitem'
+    role_required = 'part.change'
 
     def get_form(self):
         """ Override get_form() method to filter part selection options
@@ -2217,7 +2217,7 @@ class BomItemDelete(AjaxDeleteView):
     context_object_name = 'item'
     ajax_form_title = _('Confim BOM item deletion')
 
-    permission_required = 'part.delete_bomitem'
+    role_required = 'part.delete'
 
 
 class PartSalePriceBreakCreate(AjaxCreateView):
@@ -2227,7 +2227,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
     form_class = part_forms.EditPartSalePriceBreakForm
     ajax_form_title = _('Add Price Break')
 
-    permission_required = 'part.add_partsellpricebreak'
+    role_required = 'part.add'
     
     def get_data(self):
         return {
@@ -2278,7 +2278,7 @@ class PartSalePriceBreakEdit(AjaxUpdateView):
     form_class = part_forms.EditPartSalePriceBreakForm
     ajax_form_title = _('Edit Price Break')
 
-    permission_required = 'part.change_partsellpricebreak'
+    role_required = 'part.change'
 
     def get_form(self):
 
@@ -2295,4 +2295,4 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
     ajax_form_title = _("Delete Price Break")
     ajax_template_name = "modal_delete_form.html"
 
-    permission_required = 'part.delete_partsalepricebreak'
+    role_required = 'part.delete'

From d691b15f4b0a42f2356c6c1563c96a862e7c8a75 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 12:34:30 +1100
Subject: [PATCH 69/77] Fix conflicts

---
 InvenTree/users/models.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 24b0318695..773dec36f7 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -328,7 +328,12 @@ def create_missing_rule_sets(sender, instance, **kwargs):
     group permissions.
     """
 
-    update_group_roles(instance)
+    created = kwargs.get('created', False)
+    # To trigger the group permissions update: update_fields should not be None
+    update_fields = kwargs.get('update_fields', None)
+
+    if created or update_fields:
+        update_group_roles(instance)
 
 
 def check_user_role(user, role, permission):

From b3e4efd96e79cfe1562d249bbd17502c08e12deb Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 16:03:19 +1100
Subject: [PATCH 70/77] Unit testing fixes

---
 InvenTree/part/test_api.py   | 35 ++++++++++++++---------------------
 InvenTree/part/test_views.py | 31 +++++++++++--------------------
 InvenTree/users/models.py    | 11 +++++------
 3 files changed, 30 insertions(+), 47 deletions(-)

diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index 9fdc2688cb..328ad3ef9e 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -3,13 +3,13 @@ from rest_framework import status
 
 from django.urls import reverse
 from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
 
 from part.models import Part
 from stock.models import StockItem
 from company.models import Company
 
 from InvenTree.status_codes import StockStatus
-from InvenTree.helpers import addUserPermissions
 
 
 class PartAPITest(APITestCase):
@@ -36,27 +36,20 @@ class PartAPITest(APITestCase):
             password='password'
         )
 
-        # Add the permissions required to access the API endpoints
-        perms = [
-            'view_part',
-            'add_part',
-            'change_part',
-            'delete_part',
-            'view_partcategory',
-            'add_partcategory',
-            'change_partcategory',
-            'view_bomitem',
-            'add_bomitem',
-            'change_bomitem',
-            'view_partattachment',
-            'change_partattachment',
-            'add_partattachment',
-            'view_parttesttemplate',
-            'add_parttesttemplate',
-            'change_parttesttemplate',
-        ]
+        # Put the user into a group with the correct permissions
+        group = Group.objects.create(name='mygroup')
+        self.user.groups.add(group)
 
-        addUserPermissions(self.user, perms)
+        # Give the group *all* the permissions!
+        for rule in group.rule_sets.all():
+            rule.can_view = True
+            rule.can_change = True
+            rule.can_add = True
+            rule.can_delete = True
+
+            rule.save()
+
+        group.save()
 
         self.client.login(username='testuser', password='password')
 
diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index b1ae991a0c..d8c345d243 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -3,8 +3,7 @@
 from django.test import TestCase
 from django.urls import reverse
 from django.contrib.auth import get_user_model
-
-from InvenTree.helpers import addUserPermissions
+from django.contrib.auth.models import Group
 
 from .models import Part
 
@@ -31,26 +30,18 @@ class PartViewTestCase(TestCase):
             password='password'
         )
 
-        # Add the permissions required to access the pages
-        perms = [
-            'view_part',
-            'add_part',
-            'change_part',
-            'delete_part',
-            'view_partcategory',
-            'add_partcategory',
-            'change_partcategory',
-            'view_bomitem',
-            'add_bomitem',
-            'change_bomitem',
-            'view_partattachment',
-            'change_partattachment',
-            'add_partattachment',
-        ]
+        # Put the user into a group with the correct permissions
+        group = Group.objects.create(name='mygroup')
+        self.user.groups.add(group)
 
-        addUserPermissions(self.user, perms)
+        # Give the group *all* the permissions!
+        for rule in group.rule_sets.all():
+            rule.can_view = True
+            rule.can_change = True
+            rule.can_add = True
+            rule.can_delete = True
 
-        self.user.save()
+            rule.save()
 
         self.client.login(username='username', password='password')
 
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 773dec36f7..050bbe2ef9 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -171,6 +171,10 @@ class RuleSet(models.Model):
 
         super().save(*args, **kwargs)
 
+        if self.group:
+            # Update the group too!
+            self.group.save()
+
     def get_models(self):
         """
         Return the database tables / models that this ruleset covers.
@@ -328,12 +332,7 @@ def create_missing_rule_sets(sender, instance, **kwargs):
     group permissions.
     """
 
-    created = kwargs.get('created', False)
-    # To trigger the group permissions update: update_fields should not be None
-    update_fields = kwargs.get('update_fields', None)
-
-    if created or update_fields:
-        update_group_roles(instance)
+    update_group_roles(instance)
 
 
 def check_user_role(user, role, permission):

From 88f73443ee6922980356adc15e28aa6df55875b4 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 16:43:39 +1100
Subject: [PATCH 71/77] Yet more style fixes

---
 InvenTree/users/models.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 6513e979aa..fcf137bb01 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -338,9 +338,6 @@ def create_missing_rule_sets(sender, instance, **kwargs):
     then we can now use these RuleSet values to update the
     group permissions.
     """
-    created = kwargs.get('created', False)
-    # To trigger the group permissions update: update_fields should not be None
-    update_fields = kwargs.get('update_fields', None)
 
     update_group_roles(instance)
 

From ab454e5ba4fdac8567ef2f7656fb60120f20a205 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 16:46:13 +1100
Subject: [PATCH 72/77] More template changes: perms -> roles

---
 InvenTree/build/templates/build/build_base.html       | 2 +-
 InvenTree/company/templates/company/company_base.html | 2 +-
 InvenTree/order/templates/order/order_base.html       | 2 +-
 InvenTree/order/templates/order/sales_order_base.html | 2 +-
 InvenTree/part/templates/part/part_base.html          | 4 ++--
 InvenTree/part/templates/part/tabs.html               | 4 ++--
 InvenTree/stock/templates/stock/item_base.html        | 2 +-
 InvenTree/stock/templates/stock/location.html         | 2 +-
 InvenTree/templates/slide.html                        | 2 +-
 9 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html
index 915433b055..ed3da576d5 100644
--- a/InvenTree/build/templates/build/build_base.html
+++ b/InvenTree/build/templates/build/build_base.html
@@ -35,7 +35,7 @@ src="{% static 'img/blank_image.png' %}"
 <hr>
 <h4>
     {{ build.quantity }} x {{ build.part.full_name }}
-    {% if user.is_staff and perms.build.change_build %}
+    {% if user.is_staff and roles.build.change %}
     <a  href="{% url 'admin:build_build_change' build.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
 {% endif %}
 </h4>
diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html
index 73ebecf979..f20107277d 100644
--- a/InvenTree/company/templates/company/company_base.html
+++ b/InvenTree/company/templates/company/company_base.html
@@ -23,7 +23,7 @@ InvenTree | {% trans "Company" %} - {{ company.name }}
 <hr>
 <h4>
     {{ company.name }}
-    {% if user.is_staff and perms.company.change_company %}
+    {% if user.is_staff and roles.company.change %}
     <a  href="{% url 'admin:company_company_change' company.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
     {% endif %}
 </h4>
diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html
index 036021b12d..71e3455722 100644
--- a/InvenTree/order/templates/order/order_base.html
+++ b/InvenTree/order/templates/order/order_base.html
@@ -24,7 +24,7 @@ src="{% static 'img/blank_image.png' %}"
 <hr>
 <h4>
     {{ order }}
-    {% if user.is_staff and perms.order.change_purchaseorder %}
+    {% if user.is_staff and roles.purchase_order.change %}
     <a href="{% url 'admin:order_purchaseorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
     {% endif %}
 </h4>
diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html
index 70505ddccc..0572104e09 100644
--- a/InvenTree/order/templates/order/sales_order_base.html
+++ b/InvenTree/order/templates/order/sales_order_base.html
@@ -34,7 +34,7 @@ src="{% static 'img/blank_image.png' %}"
 <hr>
 <h4>
     {{ order }}
-    {% if user.is_staff and perms.order.change_salesorder %}
+    {% if user.is_staff and roles.sales_order.change %}
     <a href="{% url 'admin:order_salesorder_change' order.pk %}"><span title='{% trans "Admin view" %}' class='fas fa-user-shield'></span></a>
     {% endif %}
 </h4>
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 750f3f3806..d7604deae4 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -28,7 +28,7 @@
     <div class="media-body">
         <h3>
             {{ part.full_name }}
-            {% if user.is_staff and perms.part.change_part %}
+            {% if user.is_staff and roles.part.change %}
             <a  href="{% url 'admin:part_part_change' part.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
             {% endif %}
             {% if not part.active %}
@@ -315,7 +315,7 @@
     });
     {% endif %}
 
-    {% if not part.active and perms.part.delete_part %}
+    {% if not part.active and roles.part.delete %}
     $("#part-delete").click(function() {
         launchModalForm(
             "{% url 'part-delete' part.id %}",
diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html
index e36675eeab..8322a225bc 100644
--- a/InvenTree/part/templates/part/tabs.html
+++ b/InvenTree/part/templates/part/tabs.html
@@ -36,7 +36,7 @@
     <li{% ifequal tab 'used' %} class="active"{% endifequal %}>
         <a href="{% url 'part-used-in' part.id %}">{% trans "Used In" %} {% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li>
     {% endif %}
-    {% if part.purchaseable and perms.order.view_purchaseorder %}
+    {% if part.purchaseable and roles.purchase_order.view %}
     {% if part.is_template == False %}
     <li{% ifequal tab 'suppliers' %} class="active"{% endifequal %}>
         <a href="{% url 'part-suppliers' part.id %}">{% trans "Suppliers" %}
@@ -48,7 +48,7 @@
         <a href="{% url 'part-orders' part.id %}">{% trans "Purchase Orders" %} <span class='badge'>{{ part.purchase_orders|length }}</span></a>
     </li>
     {% endif %}
-    {% if part.salable and perms.order.view_salesorder %}
+    {% if part.salable and roles.sales_order.view %}
     <li {% if tab == 'sales-prices' %}class='active'{% endif %}>
         <a href="{% url 'part-sale-prices' part.id %}">{% trans "Sale Price" %}</a>
     </li>
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index b3fb9af743..928aa6b7a1 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -65,7 +65,7 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
 {% else %}
     <a href='{% url "part-detail" item.part.pk %}'>{{ item.part.full_name }}</a> &times {% decimal item.quantity %}
 {% endif %}
-{% if user.is_staff and perms.stock.change_stockitem %}
+{% if user.is_staff and roles.stock.change %}
     <a  href="{% url 'admin:stock_stockitem_change' item.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
 {% endif %}
 </h4>
diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html
index 2f319f4925..d411891078 100644
--- a/InvenTree/stock/templates/stock/location.html
+++ b/InvenTree/stock/templates/stock/location.html
@@ -8,7 +8,7 @@
     {% if location %}
     <h3>
         {{ location.name }}
-        {% if user.is_staff and perms.stock.change_stocklocation %}
+        {% if user.is_staff and roles.stock.change %}
         <a  href="{% url 'admin:stock_stocklocation_change' location.pk %}"><span title="{% trans 'Admin view' %}" class='fas fa-user-shield'></span></a>
         {% endif %}
     </h3>
diff --git a/InvenTree/templates/slide.html b/InvenTree/templates/slide.html
index 45535786c9..edd39e75a2 100644
--- a/InvenTree/templates/slide.html
+++ b/InvenTree/templates/slide.html
@@ -1,3 +1,3 @@
 <div>
-    <input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled or not perms.part.change_part %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off">
+    <input fieldname='{{ field }}' class='slidey' type="checkbox" data-offstyle='warning' data-onstyle="success" data-size='small' data-toggle="toggle" {% if disabled or not roles.part.change %}disabled {% endif %}{% if state %}checked=""{% endif %} autocomplete="off">
 </div>
\ No newline at end of file

From 1c97aaf87a4474df73198b2b6d46f702ff802b66 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 19:46:53 +1100
Subject: [PATCH 73/77] Set permissions for order views

---
 InvenTree/InvenTree/views.py                  |  8 ++-
 .../templates/order/sales_order_base.html     |  2 +-
 InvenTree/order/views.py                      | 53 ++++++++++++++++---
 InvenTree/users/models.py                     |  4 ++
 4 files changed, 58 insertions(+), 9 deletions(-)

diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 198903db9a..bb7c1e6f5d 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -22,7 +22,7 @@ from django.views.generic.base import TemplateView
 from part.models import Part, PartCategory
 from stock.models import StockLocation, StockItem
 from common.models import InvenTreeSetting, ColorTheme
-from users.models import check_user_role
+from users.models import check_user_role, RuleSet
 
 from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
 from .helpers import str2bool
@@ -147,7 +147,13 @@ class InvenTreeRoleMixin(PermissionRequiredMixin):
         for required in roles_required:
 
             (role, permission) = required.split('.')
+
+            if role not in RuleSet.RULESET_NAMES:
+                raise ValueError(f"Role '{role}' is not a valid role")
             
+            if permission not in RuleSet.RULESET_PERMISSIONS:
+                raise ValueError(f"Permission '{permission}' is not a valid permission")
+
             # Return False if the user does not have *any* of the required roles
             if not check_user_role(user, role, permission):
                 return False
diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html
index 0572104e09..52856cadbd 100644
--- a/InvenTree/order/templates/order/sales_order_base.html
+++ b/InvenTree/order/templates/order/sales_order_base.html
@@ -44,7 +44,7 @@ src="{% static 'img/blank_image.png' %}"
         <button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
             <span class='fas fa-edit icon-green'></span>
         </button>
-        <button type='button' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
+        <button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
             <span class='fas fa-clipboard-list'></span>
         </button>
         {% if order.status == SalesOrderStatus.PENDING %}
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 5d140fb77c..28b8416b65 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -28,19 +28,22 @@ from . import forms as order_forms
 
 from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
 from InvenTree.helpers import DownloadFile, str2bool
+from InvenTree.views import InvenTreeRoleMixin
 
 from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus, StockStatus
 
 logger = logging.getLogger(__name__)
 
 
-class PurchaseOrderIndex(ListView):
+class PurchaseOrderIndex(InvenTreeRoleMixin, ListView):
     """ List view for all purchase orders """
 
     model = PurchaseOrder
     template_name = 'order/purchase_orders.html'
     context_object_name = 'orders'
 
+    role_required = 'purchase_order.view'
+
     def get_queryset(self):
         """ Retrieve the list of purchase orders,
         ensure that the most recent ones are returned first. """
@@ -55,19 +58,21 @@ class PurchaseOrderIndex(ListView):
         return ctx
 
 
-class SalesOrderIndex(ListView):
+class SalesOrderIndex(InvenTreeRoleMixin, ListView):
 
     model = SalesOrder
     template_name = 'order/sales_orders.html'
     context_object_name = 'orders'
+    role_required = 'sales_order.view'
 
 
-class PurchaseOrderDetail(DetailView):
+class PurchaseOrderDetail(InvenTreeRoleMixin, DetailView):
     """ Detail view for a PurchaseOrder object """
 
     context_object_name = 'order'
     queryset = PurchaseOrder.objects.all().prefetch_related('lines')
     template_name = 'order/purchase_order_detail.html'
+    role_required = 'purchase_order.view'
 
     def get_context_data(self, **kwargs):
         ctx = super().get_context_data(**kwargs)
@@ -75,12 +80,13 @@ class PurchaseOrderDetail(DetailView):
         return ctx
 
 
-class SalesOrderDetail(DetailView):
+class SalesOrderDetail(InvenTreeRoleMixin, DetailView):
     """ Detail view for a SalesOrder object """
 
     context_object_name = 'order'
     queryset = SalesOrder.objects.all().prefetch_related('lines')
     template_name = 'order/sales_order_detail.html'
+    role_required = 'sales_order.view'
 
 
 class PurchaseOrderAttachmentCreate(AjaxCreateView):
@@ -92,6 +98,7 @@ class PurchaseOrderAttachmentCreate(AjaxCreateView):
     form_class = order_forms.EditPurchaseOrderAttachmentForm
     ajax_form_title = _("Add Purchase Order Attachment")
     ajax_template_name = "modal_form.html"
+    role_required = 'purchase_order.add'
 
     def post_save(self, **kwargs):
         self.object.user = self.request.user
@@ -139,6 +146,7 @@ class SalesOrderAttachmentCreate(AjaxCreateView):
     model = SalesOrderAttachment
     form_class = order_forms.EditSalesOrderAttachmentForm
     ajax_form_title = _('Add Sales Order Attachment')
+    role_required = 'sales_order.add'
 
     def post_save(self, **kwargs):
         self.object.user = self.request.user
@@ -174,6 +182,7 @@ class PurchaseOrderAttachmentEdit(AjaxUpdateView):
     model = PurchaseOrderAttachment
     form_class = order_forms.EditPurchaseOrderAttachmentForm
     ajax_form_title = _("Edit Attachment")
+    role_required = 'purchase_order.change'
 
     def get_data(self):
         return {
@@ -195,6 +204,7 @@ class SalesOrderAttachmentEdit(AjaxUpdateView):
     model = SalesOrderAttachment
     form_class = order_forms.EditSalesOrderAttachmentForm
     ajax_form_title = _("Edit Attachment")
+    role_required = 'sales_order.change'
 
     def get_data(self):
         return {
@@ -216,6 +226,7 @@ class PurchaseOrderAttachmentDelete(AjaxDeleteView):
     ajax_form_title = _("Delete Attachment")
     ajax_template_name = "order/delete_attachment.html"
     context_object_name = "attachment"
+    role_required = 'purchase_order.delete'
 
     def get_data(self):
         return {
@@ -230,6 +241,7 @@ class SalesOrderAttachmentDelete(AjaxDeleteView):
     ajax_form_title = _("Delete Attachment")
     ajax_template_name = "order/delete_attachment.html"
     context_object_name = "attachment"
+    role_required = 'sales_order.delete'
 
     def get_data(self):
         return {
@@ -237,12 +249,13 @@ class SalesOrderAttachmentDelete(AjaxDeleteView):
         }
 
 
-class PurchaseOrderNotes(UpdateView):
+class PurchaseOrderNotes(InvenTreeRoleMixin, UpdateView):
     """ View for updating the 'notes' field of a PurchaseOrder """
 
     context_object_name = 'order'
     template_name = 'order/order_notes.html'
     model = PurchaseOrder
+    role_required = 'purchase_order.change'
 
     fields = ['notes']
 
@@ -259,12 +272,13 @@ class PurchaseOrderNotes(UpdateView):
         return ctx
 
 
-class SalesOrderNotes(UpdateView):
+class SalesOrderNotes(InvenTreeRoleMixin, UpdateView):
     """ View for editing the 'notes' field of a SalesORder """
 
     context_object_name = 'order'
     template_name = 'order/sales_order_notes.html'
     model = SalesOrder
+    role_required = 'sales_order.view'
 
     fields = ['notes']
 
@@ -286,6 +300,7 @@ class PurchaseOrderCreate(AjaxCreateView):
     model = PurchaseOrder
     ajax_form_title = _("Create Purchase Order")
     form_class = order_forms.EditPurchaseOrderForm
+    role_required = 'purchase_order.add'
 
     def get_initial(self):
         initials = super().get_initial().copy()
@@ -317,6 +332,7 @@ class SalesOrderCreate(AjaxCreateView):
     model = SalesOrder
     ajax_form_title = _("Create Sales Order")
     form_class = order_forms.EditSalesOrderForm
+    role_required = 'sales_order.add'
 
     def get_initial(self):
         initials = super().get_initial().copy()
@@ -347,6 +363,7 @@ class PurchaseOrderEdit(AjaxUpdateView):
     model = PurchaseOrder
     ajax_form_title = _('Edit Purchase Order')
     form_class = order_forms.EditPurchaseOrderForm
+    role_required = 'purchase_order.change'
 
     def get_form(self):
 
@@ -367,6 +384,7 @@ class SalesOrderEdit(AjaxUpdateView):
     model = SalesOrder
     ajax_form_title = _('Edit Sales Order')
     form_class = order_forms.EditSalesOrderForm
+    role_required = 'sales_order.change'
 
     def get_form(self):
         form = super().get_form()
@@ -384,6 +402,7 @@ class PurchaseOrderCancel(AjaxUpdateView):
     ajax_form_title = _('Cancel Order')
     ajax_template_name = 'order/order_cancel.html'
     form_class = order_forms.CancelPurchaseOrderForm
+    role_required = 'purchase_order.change'
 
     def post(self, request, *args, **kwargs):
         """ Mark the PO as 'CANCELLED' """
@@ -417,6 +436,7 @@ class SalesOrderCancel(AjaxUpdateView):
     ajax_form_title = _("Cancel sales order")
     ajax_template_name = "order/sales_order_cancel.html"
     form_class = order_forms.CancelSalesOrderForm
+    role_required = 'sales_order.change'
 
     def post(self, request, *args, **kwargs):
 
@@ -451,6 +471,7 @@ class PurchaseOrderIssue(AjaxUpdateView):
     ajax_form_title = _('Issue Order')
     ajax_template_name = "order/order_issue.html"
     form_class = order_forms.IssuePurchaseOrderForm
+    role_required = 'purchase_order.change'
 
     def post(self, request, *args, **kwargs):
         """ Mark the purchase order as 'PLACED' """
@@ -486,6 +507,7 @@ class PurchaseOrderComplete(AjaxUpdateView):
     ajax_template_name = "order/order_complete.html"
     ajax_form_title = _("Complete Order")
     context_object_name = 'order'
+    role_required = 'purchase_order.change'
 
     def get_context_data(self):
 
@@ -520,6 +542,7 @@ class SalesOrderShip(AjaxUpdateView):
     context_object_name = 'order'
     ajax_template_name = 'order/sales_order_ship.html'
     ajax_form_title = _('Ship Order')
+    role_required = 'sales_order.change'
 
     def post(self, request, *args, **kwargs):
 
@@ -563,6 +586,7 @@ class PurchaseOrderExport(AjaxView):
     """
 
     model = PurchaseOrder
+    role_required = 'purchase_order.view'
 
     def get(self, request, *args, **kwargs):
 
@@ -594,6 +618,7 @@ class PurchaseOrderReceive(AjaxUpdateView):
     form_class = order_forms.ReceivePurchaseOrderForm
     ajax_form_title = _("Receive Parts")
     ajax_template_name = "order/receive_parts.html"
+    role_required = 'purchase_order.change'
 
     # Where the parts will be going (selected in POST request)
     destination = None
@@ -779,6 +804,11 @@ class OrderParts(AjaxView):
     ajax_form_title = _("Order Parts")
     ajax_template_name = 'order/order_wizard/select_parts.html'
 
+    role_required = [
+        'part.view',
+        'purchase_order.change',
+    ]
+
     # List of Parts we wish to order
     parts = []
     suppliers = []
@@ -1085,6 +1115,7 @@ class POLineItemCreate(AjaxCreateView):
     context_object_name = 'line'
     form_class = order_forms.EditPurchaseOrderLineItemForm
     ajax_form_title = _('Add Line Item')
+    role_required = 'purchase_order.add'
 
     def post(self, request, *arg, **kwargs):
 
@@ -1199,6 +1230,7 @@ class SOLineItemCreate(AjaxCreateView):
     context_order_name = 'line'
     form_class = order_forms.EditSalesOrderLineItemForm
     ajax_form_title = _('Add Line Item')
+    role_required = 'sales_order.add'
 
     def get_form(self, *args, **kwargs):
 
@@ -1250,6 +1282,7 @@ class SOLineItemEdit(AjaxUpdateView):
     model = SalesOrderLineItem
     form_class = order_forms.EditSalesOrderLineItemForm
     ajax_form_title = _('Edit Line Item')
+    role_required = 'sales_order.change'
 
     def get_form(self):
         form = super().get_form()
@@ -1268,6 +1301,7 @@ class POLineItemEdit(AjaxUpdateView):
     form_class = order_forms.EditPurchaseOrderLineItemForm
     ajax_template_name = 'modal_form.html'
     ajax_form_title = _('Edit Line Item')
+    role_required = 'purchase_order.change'
 
     def get_form(self):
         form = super().get_form()
@@ -1285,7 +1319,8 @@ class POLineItemDelete(AjaxDeleteView):
     model = PurchaseOrderLineItem
     ajax_form_title = _('Delete Line Item')
     ajax_template_name = 'order/po_lineitem_delete.html'
-    
+    role_required = 'purchase_order.delete'
+
     def get_data(self):
         return {
             'danger': _('Deleted line item'),
@@ -1297,6 +1332,7 @@ class SOLineItemDelete(AjaxDeleteView):
     model = SalesOrderLineItem
     ajax_form_title = _("Delete Line Item")
     ajax_template_name = "order/so_lineitem_delete.html"
+    role_required = 'sales_order.delete'
 
     def get_data(self):
         return {
@@ -1310,6 +1346,7 @@ class SalesOrderAllocationCreate(AjaxCreateView):
     model = SalesOrderAllocation
     form_class = order_forms.EditSalesOrderAllocationForm
     ajax_form_title = _('Allocate Stock to Order')
+    role_required = 'sales_order.add'
 
     def get_initial(self):
         initials = super().get_initial().copy()
@@ -1379,6 +1416,7 @@ class SalesOrderAllocationEdit(AjaxUpdateView):
     model = SalesOrderAllocation
     form_class = order_forms.EditSalesOrderAllocationForm
     ajax_form_title = _('Edit Allocation Quantity')
+    role_required = 'sales_order.change'
 
     def get_form(self):
         form = super().get_form()
@@ -1396,3 +1434,4 @@ class SalesOrderAllocationDelete(AjaxDeleteView):
     ajax_form_title = _("Remove allocation")
     context_object_name = 'allocation'
     ajax_template_name = "order/so_allocation_delete.html"
+    role_required = 'sales_order.delete'
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index fcf137bb01..d3c713d07d 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -36,6 +36,10 @@ class RuleSet(models.Model):
         choice[0] for choice in RULESET_CHOICES
     ]
 
+    RULESET_PERMISSIONS = [
+        'view', 'add', 'change', 'delete',
+    ]
+
     RULESET_MODELS = {
         'admin': [
             'auth_group',

From 9abb211cdf35331e5f525d9a70ec9cf3af794148 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 20:09:55 +1100
Subject: [PATCH 74/77] Update template permissions

---
 .../order/templates/order/order_base.html      | 18 ++++++++++--------
 .../order/templates/order/order_notes.html     |  2 ++
 .../order/templates/order/po_attachments.html  |  1 -
 .../templates/order/purchase_order_detail.html |  6 +++---
 .../order/templates/order/purchase_orders.html |  2 ++
 .../templates/order/sales_order_base.html      |  8 +++++---
 .../order/templates/order/sales_orders.html    |  2 ++
 7 files changed, 24 insertions(+), 15 deletions(-)

diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html
index 71e3455722..3b199c1840 100644
--- a/InvenTree/order/templates/order/order_base.html
+++ b/InvenTree/order/templates/order/order_base.html
@@ -32,29 +32,31 @@ src="{% static 'img/blank_image.png' %}"
 <p>
     <div class='btn-row'>
         <div class='btn-group action-buttons'>
-            <button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
+            {% if roles.purchase_order.change %}
+            <button type='button' class='btn btn-default' id='edit-order' title='{% trans "Edit order information" %}'>
                 <span class='fas fa-edit icon-green'></span>
             </button>
-            <button type='button' class='btn btn-default' id='export-order' title='Export order to file'>
-                <span class='fas fa-file-download'></span>
-            </button>
             {% if order.status == PurchaseOrderStatus.PENDING and order.lines.count > 0 %}
-            <button type='button' class='btn btn-default' id='place-order' title='Place order'>
+            <button type='button' class='btn btn-default' id='place-order' title='{% trans "Place order" %}'>
                 <span class='fas fa-paper-plane icon-blue'></span>
             </button>
             {% elif order.status == PurchaseOrderStatus.PLACED %}
-            <button type='button' class='btn btn-default' id='receive-order' title='Receive items'>
+            <button type='button' class='btn btn-default' id='receive-order' title='{% trans "Receive items" %}'>
                 <span class='fas fa-clipboard-check'></span>
             </button>
-            <button type='button' class='btn btn-default' id='complete-order' title='Mark order as complete'>
+            <button type='button' class='btn btn-default' id='complete-order' title='{% trans "Mark order as complete" %}'>
                 <span class='fas fa-check-circle'></span>
             </button>
             {% endif %}
             {% if order.status == PurchaseOrderStatus.PENDING or order.status == PurchaseOrderStatus.PLACED %}
-            <button type='button' class='btn btn-default' id='cancel-order' title='Cancel order'>
+            <button type='button' class='btn btn-default' id='cancel-order' title='{% trans "Cancel order" %}'>
                 <span class='fas fa-times-circle icon-red'></span>
             </button>
             {% endif %}
+            {% endif %}
+            <button type='button' class='btn btn-default' id='export-order' title='{% trans "Export order to file" %}'>
+                <span class='fas fa-file-download'></span>
+            </button>
         </div>
     </div>
 </p>
diff --git a/InvenTree/order/templates/order/order_notes.html b/InvenTree/order/templates/order/order_notes.html
index 237098e10d..0b45a5da4b 100644
--- a/InvenTree/order/templates/order/order_notes.html
+++ b/InvenTree/order/templates/order/order_notes.html
@@ -28,9 +28,11 @@
     <div class='col-sm-6'>
         <h4>{% trans "Order Notes" %}</h4>
     </div>
+    {% if roles.purchase_order.change %}
     <div class='col-sm-6'>
         <button title='{% trans "Edit notes" %}' class='btn btn-default action-button float-right' id='edit-notes'><span class='fas fa-edit'></span></button>
     </div>
+    {% endif %}
 </div>
 <hr>
 <div class='panel panel-default'>
diff --git a/InvenTree/order/templates/order/po_attachments.html b/InvenTree/order/templates/order/po_attachments.html
index f5421e8760..d60d605771 100644
--- a/InvenTree/order/templates/order/po_attachments.html
+++ b/InvenTree/order/templates/order/po_attachments.html
@@ -14,7 +14,6 @@
 
 {% include "attachment_table.html" with attachments=order.attachments.all %}
 
-
 {% endblock %}
 
 {% block js_ready %}
diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html
index 9b9eddb887..642ff96d4a 100644
--- a/InvenTree/order/templates/order/purchase_order_detail.html
+++ b/InvenTree/order/templates/order/purchase_order_detail.html
@@ -12,7 +12,7 @@
 <hr>
 
 <div id='order-toolbar-buttons' class='btn-group' style='float: right;'>
-    {% if order.status == PurchaseOrderStatus.PENDING %}
+    {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.change %}
     <button type='button' class='btn btn-default' id='new-po-line'>{% trans "Add Line Item" %}</button>
     {% endif %}
 </div>
@@ -209,12 +209,12 @@ $("#po-table").inventreeTable({
 
                 var pk = row.pk;
 
-                {% if order.status == PurchaseOrderStatus.PENDING %}
+                {% if order.status == PurchaseOrderStatus.PENDING and roles.purchase_order.delete %}
                 html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
                 html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
                 {% endif %}
 
-                {% if order.status == PurchaseOrderStatus.PLACED %}
+                {% if order.status == PurchaseOrderStatus.PLACED and roles.purchase_order.change %}
                 if (row.received < row.quantity) {
                     html += makeIconButton('fa-clipboard-check', 'button-line-receive', pk, '{% trans "Receive line item" %}');
                 }
diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html
index 1019092151..d02af36ff5 100644
--- a/InvenTree/order/templates/order/purchase_orders.html
+++ b/InvenTree/order/templates/order/purchase_orders.html
@@ -14,7 +14,9 @@ InvenTree | {% trans "Purchase Orders" %}
 
 <div id='table-buttons'>
     <div class='button-toolbar container-fluid' style='float: right;'>
+        {% if roles.purchase_order.add %}
         <button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>{% trans "New Purchase Order" %}</button>
+        {% endif %}
         <div class='filter-list' id='filter-list-purchaseorder'>
             <!-- An empty div in which the filter list will be constructed -->
         </div>
diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html
index 52856cadbd..2eb13eaa64 100644
--- a/InvenTree/order/templates/order/sales_order_base.html
+++ b/InvenTree/order/templates/order/sales_order_base.html
@@ -41,12 +41,10 @@ src="{% static 'img/blank_image.png' %}"
 <p>{{ order.description }}</p>
 <div class='btn-row'>
     <div class='btn-group action-buttons'>
+        {% if roles.sales_order.change %}
         <button type='button' class='btn btn-default' id='edit-order' title='Edit order information'>
             <span class='fas fa-edit icon-green'></span>
         </button>
-        <button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
-            <span class='fas fa-clipboard-list'></span>
-        </button>
         {% if order.status == SalesOrderStatus.PENDING %}
         <button type='button' class='btn btn-default' id='ship-order' title='{% trans "Ship order" %}'>
             <span class='fas fa-paper-plane icon-blue'></span>
@@ -55,6 +53,10 @@ src="{% static 'img/blank_image.png' %}"
             <span class='fas fa-times-circle icon-red'></span>
         </button>
         {% endif %}
+        {% endif %}
+        <button type='button' disabled='' class='btn btn-default' id='packing-list' title='{% trans "Packing List" %}'>
+            <span class='fas fa-clipboard-list'></span>
+        </button>
     </div>
 </div>
 {% endblock %}
diff --git a/InvenTree/order/templates/order/sales_orders.html b/InvenTree/order/templates/order/sales_orders.html
index 4e29156773..dfe09d5d0d 100644
--- a/InvenTree/order/templates/order/sales_orders.html
+++ b/InvenTree/order/templates/order/sales_orders.html
@@ -14,7 +14,9 @@ InvenTree | {% trans "Sales Orders" %}
 
 <div id='table-buttons'>
     <div class='button-toolbar container-fluid' style='float: right;'>
+        {% if roles.sales_order.add %}
         <button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>{% trans "New Sales Order" %}</button>
+        {% endif %}
         <div class='filter-list' id='filter-list-salesorder'>
             <!-- An empty div in which the filter list will be constructed -->
         </div>

From 2325b1e4bad9a0fb21449992831337f2c7bc12c5 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 20:10:14 +1100
Subject: [PATCH 75/77] Unit test fixes

---
 InvenTree/order/test_views.py | 17 ++++++++++++++++-
 InvenTree/order/views.py      |  2 +-
 2 files changed, 17 insertions(+), 2 deletions(-)

diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py
index 932cac9060..246cc2dd48 100644
--- a/InvenTree/order/test_views.py
+++ b/InvenTree/order/test_views.py
@@ -6,6 +6,7 @@ from __future__ import unicode_literals
 from django.test import TestCase
 from django.urls import reverse
 from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
 
 from InvenTree.status_codes import PurchaseOrderStatus
 
@@ -32,7 +33,21 @@ class OrderViewTestCase(TestCase):
 
         # Create a user
         User = get_user_model()
-        User.objects.create_user('username', 'user@email.com', 'password')
+        user = User.objects.create_user('username', 'user@email.com', 'password')
+
+        # Ensure that the user has the correct permissions!
+        g = Group.objects.create(name='orders')
+        user.groups.add(g)
+
+        for rule in g.rule_sets.all():
+            if rule.name in ['purchase_order', 'sales_order']:
+                rule.can_change = True
+                rule.can_add = True
+                rule.can_delete = True
+
+                rule.save()
+
+        g.save()
 
         self.client.login(username='username', password='password')
 
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index 28b8416b65..d2d02bb2c9 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -255,7 +255,7 @@ class PurchaseOrderNotes(InvenTreeRoleMixin, UpdateView):
     context_object_name = 'order'
     template_name = 'order/order_notes.html'
     model = PurchaseOrder
-    role_required = 'purchase_order.change'
+    role_required = 'purchase_order.view'
 
     fields = ['notes']
 

From b80e4302baffe5cc79aedca35aaf85cc5ef61eff Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 20:29:16 +1100
Subject: [PATCH 76/77] Update permissions for build app

---
 .../build/templates/build/build_base.html     | 12 ++---
 InvenTree/build/tests.py                      | 45 ++++++++++++++++++-
 InvenTree/build/views.py                      | 18 +++++++-
 3 files changed, 67 insertions(+), 8 deletions(-)

diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html
index ed3da576d5..f076013def 100644
--- a/InvenTree/build/templates/build/build_base.html
+++ b/InvenTree/build/templates/build/build_base.html
@@ -41,19 +41,21 @@ src="{% static 'img/blank_image.png' %}"
 </h4>
 <div class='btn-row'>
     <div class='btn-group action-buttons'>
-        <button type='button' class='btn btn-default' id='build-edit' title='Edit Build'>
+        {% if roles.build.change %}
+        <button type='button' class='btn btn-default' id='build-edit' title='{% trans "Edit Build" %}'>
             <span class='fas fa-edit icon-green'/>
         </button>
         {% if build.is_active %}
-        <button type='button' class='btn btn-default' id='build-complete' title="Complete Build">
+        <button type='button' class='btn btn-default' id='build-complete' title='{% trans "Complete Build" %}'>
             <span class='fas fa-tools'/>
         </button>
-        <button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='Cancel Build'>
+        <button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='{% trans "Cancel Build" %}'>
             <span class='fas fa-times-circle icon-red'/>
         </button>
         {% endif %}
-        {% if build.status == BuildStatus.CANCELLED %}
-        <button type='button' class='btn btn-default btn-glyph' id='build-delete' title='Delete Build'>
+        {% endif %}
+        {% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
+        <button type='button' class='btn btn-default btn-glyph' id='build-delete' title='{% trans "Delete Build" %}'>
             <span class='fas fa-trash-alt icon-red'/>
         </button>
         {% endif %}
diff --git a/InvenTree/build/tests.py b/InvenTree/build/tests.py
index 9b6d51c33a..92ca034a4a 100644
--- a/InvenTree/build/tests.py
+++ b/InvenTree/build/tests.py
@@ -4,6 +4,7 @@ from __future__ import unicode_literals
 from django.test import TestCase
 from django.urls import reverse
 from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
 
 from rest_framework.test import APITestCase
 from rest_framework import status
@@ -30,6 +31,20 @@ class BuildTestSimple(TestCase):
         User.objects.create_user('testuser', 'test@testing.com', 'password')
 
         self.user = User.objects.get(username='testuser')
+
+        g = Group.objects.create(name='builders')
+        self.user.groups.add(g)
+
+        for rule in g.rule_sets.all():
+            if rule.name == 'build':
+                rule.can_change = True
+                rule.can_add = True
+                rule.can_delete = True
+
+                rule.save()
+
+        g.save()
+
         self.client.login(username='testuser', password='password')
 
     def test_build_objects(self):
@@ -94,7 +109,20 @@ class TestBuildAPI(APITestCase):
     def setUp(self):
         # Create a user for auth
         User = get_user_model()
-        User.objects.create_user('testuser', 'test@testing.com', 'password')
+        user = User.objects.create_user('testuser', 'test@testing.com', 'password')
+
+        g = Group.objects.create(name='builders')
+        user.groups.add(g)
+
+        for rule in g.rule_sets.all():
+            if rule.name == 'build':
+                rule.can_change = True
+                rule.can_add = True
+                rule.can_delete = True
+
+                rule.save()
+
+        g.save()
 
         self.client.login(username='testuser', password='password')
 
@@ -131,7 +159,20 @@ class TestBuildViews(TestCase):
 
         # Create a user
         User = get_user_model()
-        User.objects.create_user('username', 'user@email.com', 'password')
+        user = User.objects.create_user('username', 'user@email.com', 'password')
+
+        g = Group.objects.create(name='builders')
+        user.groups.add(g)
+
+        for rule in g.rule_sets.all():
+            if rule.name == 'build':
+                rule.can_change = True
+                rule.can_add = True
+                rule.can_delete = True
+
+                rule.save()
+
+        g.save()
 
         self.client.login(username='username', password='password')
 
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 88dc66085f..b2b8b24502 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -17,16 +17,18 @@ from . import forms
 from stock.models import StockLocation, StockItem
 
 from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
+from InvenTree.views import InvenTreeRoleMixin
 from InvenTree.helpers import str2bool, ExtractSerialNumbers
 from InvenTree.status_codes import BuildStatus
 
 
-class BuildIndex(ListView):
+class BuildIndex(InvenTreeRoleMixin, ListView):
     """ View for displaying list of Builds
     """
     model = Build
     template_name = 'build/index.html'
     context_object_name = 'builds'
+    role_required = 'build.view'
 
     def get_queryset(self):
         """ Return all Build objects (order by date, newest first) """
@@ -56,6 +58,7 @@ class BuildCancel(AjaxUpdateView):
     ajax_form_title = _('Cancel Build')
     context_object_name = 'build'
     form_class = forms.CancelBuildForm
+    role_required = 'build.change'
 
     def post(self, request, *args, **kwargs):
         """ Handle POST request. Mark the build status as CANCELLED """
@@ -94,6 +97,7 @@ class BuildAutoAllocate(AjaxUpdateView):
     context_object_name = 'build'
     ajax_form_title = _('Allocate Stock')
     ajax_template_name = 'build/auto_allocate.html'
+    role_required = 'build.change'
 
     def get_context_data(self, *args, **kwargs):
         """ Get the context data for form rendering. """
@@ -147,6 +151,7 @@ class BuildUnallocate(AjaxUpdateView):
     form_class = forms.ConfirmBuildForm
     ajax_form_title = _("Unallocate Stock")
     ajax_template_name = "build/unallocate.html"
+    form_required = 'build.change'
 
     def post(self, request, *args, **kwargs):
 
@@ -184,6 +189,7 @@ class BuildComplete(AjaxUpdateView):
     context_object_name = "build"
     ajax_form_title = _("Complete Build")
     ajax_template_name = "build/complete.html"
+    role_required = 'build.change'
 
     def get_form(self):
         """ Get the form object.
@@ -325,6 +331,7 @@ class BuildNotes(UpdateView):
     context_object_name = 'build'
     template_name = 'build/notes.html'
     model = Build
+    role_required = 'build.view'
 
     fields = ['notes']
 
@@ -342,9 +349,11 @@ class BuildNotes(UpdateView):
 
 class BuildDetail(DetailView):
     """ Detail view of a single Build object. """
+
     model = Build
     template_name = 'build/detail.html'
     context_object_name = 'build'
+    role_required = 'build.view'
 
     def get_context_data(self, **kwargs):
 
@@ -363,6 +372,7 @@ class BuildAllocate(DetailView):
     model = Build
     context_object_name = 'build'
     template_name = 'build/allocate.html'
+    role_required = ['build.change']
 
     def get_context_data(self, **kwargs):
         """ Provide extra context information for the Build allocation page """
@@ -392,6 +402,7 @@ class BuildCreate(AjaxCreateView):
     form_class = forms.EditBuildForm
     ajax_form_title = _('Start new Build')
     ajax_template_name = 'modal_form.html'
+    role_required = 'build.add'
 
     def get_initial(self):
         """ Get initial parameters for Build creation.
@@ -427,6 +438,7 @@ class BuildUpdate(AjaxUpdateView):
     context_object_name = 'build'
     ajax_form_title = _('Edit Build Details')
     ajax_template_name = 'modal_form.html'
+    role_required = 'build.change'
 
     def get_data(self):
         return {
@@ -440,6 +452,7 @@ class BuildDelete(AjaxDeleteView):
     model = Build
     ajax_template_name = 'build/delete_build.html'
     ajax_form_title = _('Delete Build')
+    role_required = 'build.delete'
 
 
 class BuildItemDelete(AjaxDeleteView):
@@ -451,6 +464,7 @@ class BuildItemDelete(AjaxDeleteView):
     ajax_template_name = 'build/delete_build_item.html'
     ajax_form_title = _('Unallocate Stock')
     context_object_name = 'item'
+    role_required = 'build.delete'
 
     def get_data(self):
         return {
@@ -465,6 +479,7 @@ class BuildItemCreate(AjaxCreateView):
     form_class = forms.EditBuildItemForm
     ajax_template_name = 'build/create_build_item.html'
     ajax_form_title = _('Allocate new Part')
+    role_required = 'build.add'
 
     part = None
     available_stock = None
@@ -618,6 +633,7 @@ class BuildItemEdit(AjaxUpdateView):
     ajax_template_name = 'modal_form.html'
     form_class = forms.EditBuildItemForm
     ajax_form_title = _('Edit Stock Allocation')
+    role_required = 'build.change'
 
     def get_data(self):
         return {

From 8b37229e4b3b84c5e241f5e4418e2f1eaee3fc29 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 6 Oct 2020 20:31:38 +1100
Subject: [PATCH 77/77] The real translations are the ones we made along the
 way

---
 InvenTree/locale/de/LC_MESSAGES/django.po | 442 ++++++++++++----------
 InvenTree/locale/en/LC_MESSAGES/django.po | 416 ++++++++++----------
 InvenTree/locale/es/LC_MESSAGES/django.po | 416 ++++++++++----------
 3 files changed, 668 insertions(+), 606 deletions(-)

diff --git a/InvenTree/locale/de/LC_MESSAGES/django.po b/InvenTree/locale/de/LC_MESSAGES/django.po
index 7632fadc4c..7f955ffea2 100644
--- a/InvenTree/locale/de/LC_MESSAGES/django.po
+++ b/InvenTree/locale/de/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-10-05 13:20+0000\n"
+"POT-Creation-Date: 2020-10-06 09:31+0000\n"
 "PO-Revision-Date: 2020-05-03 11:32+0200\n"
 "Last-Translator: Christian Schlüter <chschlue@gmail.com>\n"
 "Language-Team: C <kde-i18n-doc@kde.org>\n"
@@ -17,11 +17,11 @@ msgstr ""
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 "X-Generator: Lokalize 19.12.0\n"
 
-#: InvenTree/api.py:83
+#: InvenTree/api.py:85
 msgid "No action specified"
 msgstr "Keine Aktion angegeben"
 
-#: InvenTree/api.py:97
+#: InvenTree/api.py:99
 msgid "No matching action found"
 msgstr "Keine passende Aktion gefunden"
 
@@ -49,35 +49,35 @@ msgstr ""
 msgid "Apply Theme"
 msgstr ""
 
-#: InvenTree/helpers.py:337 order/models.py:187 order/models.py:261
+#: InvenTree/helpers.py:339 order/models.py:187 order/models.py:261
 msgid "Invalid quantity provided"
 msgstr "Keine gültige Menge"
 
-#: InvenTree/helpers.py:340
+#: InvenTree/helpers.py:342
 msgid "Empty serial number string"
 msgstr "Keine Seriennummer angegeben"
 
-#: InvenTree/helpers.py:361
+#: InvenTree/helpers.py:363
 #, python-brace-format
 msgid "Duplicate serial: {n}"
 msgstr "Doppelte Seriennummer: {n}"
 
-#: InvenTree/helpers.py:365 InvenTree/helpers.py:368 InvenTree/helpers.py:371
+#: InvenTree/helpers.py:367 InvenTree/helpers.py:370 InvenTree/helpers.py:373
 #, python-brace-format
 msgid "Invalid group: {g}"
 msgstr "Ungültige Gruppe: {g}"
 
-#: InvenTree/helpers.py:376
+#: InvenTree/helpers.py:378
 #, fuzzy, python-brace-format
 #| msgid "Duplicate serial: {n}"
 msgid "Duplicate serial: {g}"
 msgstr "Doppelte Seriennummer: {n}"
 
-#: InvenTree/helpers.py:384
+#: InvenTree/helpers.py:386
 msgid "No serial numbers found"
 msgstr "Keine Seriennummern gefunden"
 
-#: InvenTree/helpers.py:388
+#: InvenTree/helpers.py:390
 #, python-brace-format
 msgid "Number of unique serial number ({s}) must match quantity ({q})"
 msgstr ""
@@ -107,19 +107,19 @@ msgstr "Name"
 msgid "Description (optional)"
 msgstr "Firmenbeschreibung"
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:348
 msgid "English"
 msgstr "Englisch"
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:349
 msgid "German"
 msgstr "Deutsch"
 
-#: InvenTree/settings.py:345
+#: InvenTree/settings.py:350
 msgid "French"
 msgstr "Französisch"
 
-#: InvenTree/settings.py:346
+#: InvenTree/settings.py:351
 msgid "Polish"
 msgstr "Polnisch"
 
@@ -152,7 +152,7 @@ msgid "Returned"
 msgstr "Zurückgegeben"
 
 #: InvenTree/status_codes.py:136
-#: order/templates/order/sales_order_base.html:103
+#: order/templates/order/sales_order_base.html:105
 msgid "Shipped"
 msgstr "Versendet"
 
@@ -207,7 +207,7 @@ msgstr "Überschuss darf 100% nicht überschreiten"
 msgid "Overage must be an integer value or a percentage"
 msgstr "Überschuss muss eine Ganzzahl oder ein Prozentwert sein"
 
-#: InvenTree/views.py:661
+#: InvenTree/views.py:703
 msgid "Database Statistics"
 msgstr "Datenbankstatistiken"
 
@@ -281,7 +281,7 @@ msgstr "Bau-Fertigstellung bestätigen"
 msgid "Build quantity must be integer value for trackable parts"
 msgstr "Überschuss muss eine Ganzzahl oder ein Prozentwert sein"
 
-#: build/models.py:73 build/templates/build/build_base.html:70
+#: build/models.py:73 build/templates/build/build_base.html:72
 msgid "Build Title"
 msgstr "Bau-Titel"
 
@@ -289,7 +289,7 @@ msgstr "Bau-Titel"
 msgid "Brief description of the build"
 msgstr "Kurze Beschreibung des Baus"
 
-#: build/models.py:84 build/templates/build/build_base.html:91
+#: build/models.py:84 build/templates/build/build_base.html:93
 msgid "Parent Build"
 msgstr "Eltern-Bau"
 
@@ -299,7 +299,7 @@ msgstr "Eltern-Bau, dem dieser Bau zugewiesen ist"
 
 #: build/models.py:90 build/templates/build/allocate.html:329
 #: build/templates/build/auto_allocate.html:19
-#: build/templates/build/build_base.html:75
+#: build/templates/build/build_base.html:77
 #: build/templates/build/detail.html:22 order/models.py:501
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:147
@@ -424,7 +424,7 @@ msgid "Stock quantity to allocate to build"
 msgstr "Lagerobjekt-Anzahl dem Bau zuweisen"
 
 #: build/templates/build/allocate.html:17
-#: company/templates/company/detail_part.html:18 order/views.py:779
+#: company/templates/company/detail_part.html:18 order/views.py:804
 #: part/templates/part/category.html:122
 msgid "Order Parts"
 msgstr "Teile bestellen"
@@ -458,7 +458,7 @@ msgstr "Seriennummer"
 
 #: build/templates/build/allocate.html:172
 #: build/templates/build/auto_allocate.html:20
-#: build/templates/build/build_base.html:80
+#: build/templates/build/build_base.html:82
 #: build/templates/build/detail.html:27
 #: company/templates/company/supplier_part_pricing.html:71
 #: order/templates/order/order_wizard/select_parts.html:32
@@ -598,11 +598,29 @@ msgstr "Dieser Bau ist Kind von Bau"
 msgid "Admin view"
 msgstr "Admin"
 
-#: build/templates/build/build_base.html:66 build/templates/build/detail.html:9
+#: build/templates/build/build_base.html:45
+#, fuzzy
+#| msgid "Edited build"
+msgid "Edit Build"
+msgstr "Bau bearbeitet"
+
+#: build/templates/build/build_base.html:49 build/views.py:190
+msgid "Complete Build"
+msgstr "Bau fertigstellen"
+
+#: build/templates/build/build_base.html:52 build/views.py:58
+msgid "Cancel Build"
+msgstr "Bau abbrechen"
+
+#: build/templates/build/build_base.html:58 build/views.py:454
+msgid "Delete Build"
+msgstr "Bau entfernt"
+
+#: build/templates/build/build_base.html:68 build/templates/build/detail.html:9
 msgid "Build Details"
 msgstr "Bau-Status"
 
-#: build/templates/build/build_base.html:85
+#: build/templates/build/build_base.html:87
 #: build/templates/build/detail.html:42
 #: order/templates/order/receive_parts.html:24
 #: stock/templates/stock/item_base.html:276 templates/InvenTree/search.html:175
@@ -612,7 +630,7 @@ msgstr "Bau-Status"
 msgid "Status"
 msgstr "Status"
 
-#: build/templates/build/build_base.html:98 order/models.py:499
+#: build/templates/build/build_base.html:100 order/models.py:499
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
@@ -622,15 +640,15 @@ msgstr "Status"
 msgid "Sales Order"
 msgstr "Bestellung"
 
-#: build/templates/build/build_base.html:104
+#: build/templates/build/build_base.html:106
 msgid "BOM Price"
 msgstr "Stücklistenpreis"
 
-#: build/templates/build/build_base.html:109
+#: build/templates/build/build_base.html:111
 msgid "BOM pricing is incomplete"
 msgstr "Stücklistenbepreisung ist unvollständig"
 
-#: build/templates/build/build_base.html:112
+#: build/templates/build/build_base.html:114
 msgid "No pricing information"
 msgstr "Keine Preisinformation"
 
@@ -694,8 +712,8 @@ msgid "Batch"
 msgstr "Los"
 
 #: build/templates/build/detail.html:61
-#: order/templates/order/order_base.html:98
-#: order/templates/order/sales_order_base.html:97 templates/js/build.html:71
+#: order/templates/order/order_base.html:100
+#: order/templates/order/sales_order_base.html:99 templates/js/build.html:71
 msgid "Created"
 msgstr "Erstellt"
 
@@ -737,7 +755,7 @@ msgid "Save"
 msgstr "Speichern"
 
 #: build/templates/build/notes.html:33 company/templates/company/notes.html:30
-#: order/templates/order/order_notes.html:32
+#: order/templates/order/order_notes.html:33
 #: order/templates/order/sales_order_notes.html:37
 #: part/templates/part/notes.html:33 stock/templates/stock/item_notes.html:33
 msgid "Edit notes"
@@ -757,100 +775,88 @@ msgid "Are you sure you wish to unallocate all stock for this build?"
 msgstr ""
 "Sind Sie sicher, dass sie alle Lagerobjekte von diesem Bau entfernen möchten?"
 
-#: build/views.py:56
-msgid "Cancel Build"
-msgstr "Bau abbrechen"
-
-#: build/views.py:74
+#: build/views.py:77
 msgid "Confirm build cancellation"
 msgstr "Bauabbruch bestätigen"
 
-#: build/views.py:79
+#: build/views.py:82
 msgid "Build was cancelled"
 msgstr "Bau wurde abgebrochen"
 
-#: build/views.py:95
+#: build/views.py:98
 msgid "Allocate Stock"
 msgstr "Lagerbestand zuweisen"
 
-#: build/views.py:108
+#: build/views.py:112
 msgid "No matching build found"
 msgstr "Kein passender Bau gefunden"
 
-#: build/views.py:127
+#: build/views.py:131
 msgid "Confirm stock allocation"
 msgstr "Lagerbestandszuordnung bestätigen"
 
-#: build/views.py:128
+#: build/views.py:132
 msgid "Check the confirmation box at the bottom of the list"
 msgstr "Bestätigunsbox am Ende der Liste bestätigen"
 
-#: build/views.py:148 build/views.py:452
+#: build/views.py:152 build/views.py:465
 msgid "Unallocate Stock"
 msgstr "Zuweisung aufheben"
 
-#: build/views.py:161
+#: build/views.py:166
 msgid "Confirm unallocation of build stock"
 msgstr "Zuweisungsaufhebung bestätigen"
 
-#: build/views.py:162 stock/views.py:405
+#: build/views.py:167 stock/views.py:405
 msgid "Check the confirmation box"
 msgstr "Bestätigungsbox bestätigen"
 
-#: build/views.py:185
-msgid "Complete Build"
-msgstr "Bau fertigstellen"
-
-#: build/views.py:264
+#: build/views.py:270
 msgid "Confirm completion of build"
 msgstr "Baufertigstellung bestätigen"
 
-#: build/views.py:271
+#: build/views.py:277
 msgid "Invalid location selected"
 msgstr "Ungültige Ortsauswahl"
 
-#: build/views.py:296 stock/views.py:1621
+#: build/views.py:302 stock/views.py:1621
 #, python-brace-format
 msgid "The following serial numbers already exist: ({sn})"
 msgstr "Die folgende Seriennummer existiert bereits: ({sn})"
 
-#: build/views.py:317
+#: build/views.py:323
 msgid "Build marked as COMPLETE"
 msgstr "Bau als FERTIG markiert"
 
-#: build/views.py:393
+#: build/views.py:403
 msgid "Start new Build"
 msgstr "Neuen Bau beginnen"
 
-#: build/views.py:418
+#: build/views.py:429
 msgid "Created new build"
 msgstr "Neuen Bau angelegt"
 
-#: build/views.py:428
+#: build/views.py:439
 msgid "Edit Build Details"
 msgstr "Baudetails bearbeiten"
 
-#: build/views.py:433
+#: build/views.py:445
 msgid "Edited build"
 msgstr "Bau bearbeitet"
 
-#: build/views.py:442
-msgid "Delete Build"
-msgstr "Bau entfernt"
-
-#: build/views.py:457
+#: build/views.py:471
 msgid "Removed parts from build allocation"
 msgstr "Teile von Bauzuordnung entfernt"
 
-#: build/views.py:467
+#: build/views.py:481
 msgid "Allocate new Part"
 msgstr "Neues Teil zuordnen"
 
-#: build/views.py:620
+#: build/views.py:635
 msgid "Edit Stock Allocation"
 msgstr "Teilzuordnung bearbeiten"
 
-#: build/views.py:624
+#: build/views.py:640
 msgid "Updated Build Item"
 msgstr "Bauobjekt aktualisiert"
 
@@ -1055,7 +1061,7 @@ msgstr "Hersteller"
 #: company/templates/company/detail.html:21
 #: company/templates/company/supplier_part_base.html:66
 #: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:79
+#: order/templates/order/order_base.html:81
 #: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
 #: stock/templates/stock/item_base.html:251 templates/js/company.html:48
 #: templates/js/company.html:162 templates/js/order.html:146
@@ -1063,7 +1069,7 @@ msgid "Supplier"
 msgstr "Zulieferer"
 
 #: company/templates/company/detail.html:26
-#: order/templates/order/sales_order_base.html:78 stock/models.py:370
+#: order/templates/order/sales_order_base.html:80 stock/models.py:370
 #: stock/models.py:371 stock/templates/stock/item_base.html:169
 #: templates/js/company.html:40 templates/js/order.html:221
 msgid "Customer"
@@ -1169,12 +1175,12 @@ msgid "Purchase Orders"
 msgstr "Bestellungen"
 
 #: company/templates/company/purchase_orders.html:14
-#: order/templates/order/purchase_orders.html:17
+#: order/templates/order/purchase_orders.html:18
 msgid "Create new purchase order"
 msgstr "Neue Bestellung anlegen"
 
 #: company/templates/company/purchase_orders.html:14
-#: order/templates/order/purchase_orders.html:17
+#: order/templates/order/purchase_orders.html:18
 msgid "New Purchase Order"
 msgstr "Neue Bestellung"
 
@@ -1188,12 +1194,12 @@ msgid "Sales Orders"
 msgstr "Bestellungen"
 
 #: company/templates/company/sales_orders.html:14
-#: order/templates/order/sales_orders.html:17
+#: order/templates/order/sales_orders.html:18
 msgid "Create new sales order"
 msgstr "Neuen Auftrag anlegen"
 
 #: company/templates/company/sales_orders.html:14
-#: order/templates/order/sales_orders.html:17
+#: order/templates/order/sales_orders.html:18
 msgid "New Sales Order"
 msgstr "Neuer Auftrag"
 
@@ -1251,7 +1257,7 @@ msgid "Pricing Information"
 msgstr "Preisinformationen ansehen"
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2226
+#: part/templates/part/sale_prices.html:13 part/views.py:2228
 msgid "Add Price Break"
 msgstr "Preisstaffel hinzufügen"
 
@@ -1382,17 +1388,17 @@ msgstr "Neues Zuliefererteil anlegen"
 msgid "Delete Supplier Part"
 msgstr "Zuliefererteil entfernen"
 
-#: company/views.py:404 part/views.py:2232
+#: company/views.py:404 part/views.py:2234
 #, fuzzy
 #| msgid "Add Price Break"
 msgid "Added new price break"
 msgstr "Preisstaffel hinzufügen"
 
-#: company/views.py:441 part/views.py:2277
+#: company/views.py:441 part/views.py:2279
 msgid "Edit Price Break"
 msgstr "Preisstaffel bearbeiten"
 
-#: company/views.py:456 part/views.py:2293
+#: company/views.py:456 part/views.py:2295
 msgid "Delete Price Break"
 msgstr "Preisstaffel löschen"
 
@@ -1424,20 +1430,20 @@ msgstr ""
 msgid "Enabled"
 msgstr ""
 
-#: order/forms.py:24
+#: order/forms.py:24 order/templates/order/order_base.html:40
 msgid "Place order"
 msgstr "Bestellung aufgeben"
 
-#: order/forms.py:35
+#: order/forms.py:35 order/templates/order/order_base.html:47
 msgid "Mark order as complete"
 msgstr "Bestellung als vollständig markieren"
 
-#: order/forms.py:46 order/forms.py:57
-#: order/templates/order/sales_order_base.html:54
+#: order/forms.py:46 order/forms.py:57 order/templates/order/order_base.html:52
+#: order/templates/order/sales_order_base.html:52
 msgid "Cancel order"
 msgstr "Bestellung stornieren"
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:51
+#: order/forms.py:68 order/templates/order/sales_order_base.html:49
 msgid "Ship order"
 msgstr "Bestellung versenden"
 
@@ -1497,7 +1503,7 @@ msgstr ""
 msgid "Date order was completed"
 msgstr "Bestellung als vollständig markieren"
 
-#: order/models.py:185 order/models.py:259 part/views.py:1343
+#: order/models.py:185 order/models.py:259 part/views.py:1345
 #: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr "Anzahl muss größer Null sein"
@@ -1578,32 +1584,48 @@ msgstr "Zuordnungsanzahl eingeben"
 msgid "Are you sure you want to delete this attachment?"
 msgstr "Sind Sie sicher, dass Sie diesen Anhang löschen wollen?"
 
-#: order/templates/order/order_base.html:64
+#: order/templates/order/order_base.html:36
+#, fuzzy
+#| msgid "Edited company information"
+msgid "Edit order information"
+msgstr "Firmeninformation bearbeitet"
+
+#: order/templates/order/order_base.html:44
+#, fuzzy
+#| msgid "Receive line item"
+msgid "Receive items"
+msgstr "Position empfangen"
+
+#: order/templates/order/order_base.html:57
+msgid "Export order to file"
+msgstr ""
+
+#: order/templates/order/order_base.html:66
 msgid "Purchase Order Details"
 msgstr "Bestelldetails"
 
-#: order/templates/order/order_base.html:69
-#: order/templates/order/sales_order_base.html:68
+#: order/templates/order/order_base.html:71
+#: order/templates/order/sales_order_base.html:70
 msgid "Order Reference"
 msgstr "Bestellreferenz"
 
-#: order/templates/order/order_base.html:74
-#: order/templates/order/sales_order_base.html:73
+#: order/templates/order/order_base.html:76
+#: order/templates/order/sales_order_base.html:75
 msgid "Order Status"
 msgstr "Bestellstatus"
 
-#: order/templates/order/order_base.html:85 templates/js/order.html:153
+#: order/templates/order/order_base.html:87 templates/js/order.html:153
 msgid "Supplier Reference"
 msgstr "Zuliefererreferenz"
 
-#: order/templates/order/order_base.html:104
+#: order/templates/order/order_base.html:106
 msgid "Issued"
 msgstr "Aufgegeben"
 
-#: order/templates/order/order_base.html:111
+#: order/templates/order/order_base.html:113
 #: order/templates/order/purchase_order_detail.html:182
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:110
+#: order/templates/order/sales_order_base.html:112
 msgid "Received"
 msgstr "Empfangen"
 
@@ -1672,8 +1694,8 @@ msgid "Attachments"
 msgstr "Anhänge"
 
 #: order/templates/order/purchase_order_detail.html:16
-#: order/templates/order/sales_order_detail.html:17 order/views.py:1087
-#: order/views.py:1201
+#: order/templates/order/sales_order_detail.html:17 order/views.py:1117
+#: order/views.py:1232
 msgid "Add Line Item"
 msgstr "Position hinzufügen"
 
@@ -1743,15 +1765,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr "Dieser Auftrag ist nicht vollständig zugeordnet"
 
-#: order/templates/order/sales_order_base.html:47
+#: order/templates/order/sales_order_base.html:57
 msgid "Packing List"
 msgstr "Packliste"
 
-#: order/templates/order/sales_order_base.html:63
+#: order/templates/order/sales_order_base.html:65
 msgid "Sales Order Details"
 msgstr "Auftragsdetails"
 
-#: order/templates/order/sales_order_base.html:84 templates/js/order.html:228
+#: order/templates/order/sales_order_base.html:86 templates/js/order.html:228
 msgid "Customer Reference"
 msgstr "Kundenreferenz"
 
@@ -1819,147 +1841,147 @@ msgstr "Sind Sie sicher, dass Sie diese Position löschen möchten?"
 msgid "Order Items"
 msgstr "Bestellungspositionen"
 
-#: order/views.py:93
+#: order/views.py:99
 msgid "Add Purchase Order Attachment"
 msgstr "Bestellanhang hinzufügen"
 
-#: order/views.py:102 order/views.py:149 part/views.py:90 stock/views.py:167
+#: order/views.py:109 order/views.py:157 part/views.py:92 stock/views.py:167
 msgid "Added attachment"
 msgstr "Anhang hinzugefügt"
 
-#: order/views.py:141
+#: order/views.py:148
 msgid "Add Sales Order Attachment"
 msgstr "Auftragsanhang hinzufügen"
 
-#: order/views.py:176 order/views.py:197
+#: order/views.py:184 order/views.py:206
 msgid "Edit Attachment"
 msgstr "Anhang bearbeiten"
 
-#: order/views.py:180 order/views.py:201
+#: order/views.py:189 order/views.py:211
 msgid "Attachment updated"
 msgstr "Anhang aktualisiert"
 
-#: order/views.py:216 order/views.py:230
+#: order/views.py:226 order/views.py:241
 msgid "Delete Attachment"
 msgstr "Anhang löschen"
 
-#: order/views.py:222 order/views.py:236 stock/views.py:223
+#: order/views.py:233 order/views.py:248 stock/views.py:223
 msgid "Deleted attachment"
 msgstr "Anhang gelöscht"
 
-#: order/views.py:287
+#: order/views.py:301
 msgid "Create Purchase Order"
 msgstr "Bestellung anlegen"
 
-#: order/views.py:318
+#: order/views.py:333
 msgid "Create Sales Order"
 msgstr "Auftrag anlegen"
 
-#: order/views.py:348
+#: order/views.py:364
 msgid "Edit Purchase Order"
 msgstr "Bestellung bearbeiten"
 
-#: order/views.py:368
+#: order/views.py:385
 msgid "Edit Sales Order"
 msgstr "Auftrag bearbeiten"
 
-#: order/views.py:384
+#: order/views.py:402
 msgid "Cancel Order"
 msgstr "Bestellung stornieren"
 
-#: order/views.py:399 order/views.py:431
+#: order/views.py:418 order/views.py:451
 msgid "Confirm order cancellation"
 msgstr "Bestellstornierung bestätigen"
 
-#: order/views.py:417
+#: order/views.py:436
 msgid "Cancel sales order"
 msgstr "Auftrag stornieren"
 
-#: order/views.py:437
+#: order/views.py:457
 msgid "Could not cancel order"
 msgstr "Stornierung fehlgeschlagen"
 
-#: order/views.py:451
+#: order/views.py:471
 msgid "Issue Order"
 msgstr "Bestellung aufgeben"
 
-#: order/views.py:466
+#: order/views.py:487
 msgid "Confirm order placement"
 msgstr "Bestellungstätigung bestätigen"
 
-#: order/views.py:487
+#: order/views.py:508
 msgid "Complete Order"
 msgstr "Auftrag fertigstellen"
 
-#: order/views.py:522
+#: order/views.py:544
 msgid "Ship Order"
 msgstr "Versenden"
 
-#: order/views.py:538
+#: order/views.py:561
 msgid "Confirm order shipment"
 msgstr "Versand bestätigen"
 
-#: order/views.py:544
+#: order/views.py:567
 msgid "Could not ship order"
 msgstr "Versand fehlgeschlagen"
 
-#: order/views.py:595
+#: order/views.py:619
 msgid "Receive Parts"
 msgstr "Teile empfangen"
 
-#: order/views.py:662
+#: order/views.py:687
 msgid "Items received"
 msgstr "Anzahl empfangener Positionen"
 
-#: order/views.py:676
+#: order/views.py:701
 msgid "No destination set"
 msgstr "Kein Ziel gesetzt"
 
-#: order/views.py:721
+#: order/views.py:746
 msgid "Error converting quantity to number"
 msgstr "Fehler beim Konvertieren zu Zahl"
 
-#: order/views.py:727
+#: order/views.py:752
 msgid "Receive quantity less than zero"
 msgstr "Anzahl kleiner null empfangen"
 
-#: order/views.py:733
+#: order/views.py:758
 msgid "No lines specified"
 msgstr "Keine Zeilen angegeben"
 
-#: order/views.py:1107
+#: order/views.py:1138
 msgid "Invalid Purchase Order"
 msgstr "Ungültige Bestellung"
 
-#: order/views.py:1115
+#: order/views.py:1146
 msgid "Supplier must match for Part and Order"
 msgstr "Zulieferer muss zum Teil und zur Bestellung passen"
 
-#: order/views.py:1120
+#: order/views.py:1151
 msgid "Invalid SupplierPart selection"
 msgstr "Ungültige Wahl des Zulieferer-Teils"
 
-#: order/views.py:1252 order/views.py:1270
+#: order/views.py:1284 order/views.py:1303
 msgid "Edit Line Item"
 msgstr "Position bearbeiten"
 
-#: order/views.py:1286 order/views.py:1298
+#: order/views.py:1320 order/views.py:1333
 msgid "Delete Line Item"
 msgstr "Position löschen"
 
-#: order/views.py:1291 order/views.py:1303
+#: order/views.py:1326 order/views.py:1339
 msgid "Deleted line item"
 msgstr "Position gelöscht"
 
-#: order/views.py:1312
+#: order/views.py:1348
 msgid "Allocate Stock to Order"
 msgstr "Lagerbestand dem Auftrag zuweisen"
 
-#: order/views.py:1381
+#: order/views.py:1418
 msgid "Edit Allocation Quantity"
 msgstr "Zuordnung bearbeiten"
 
-#: order/views.py:1396
+#: order/views.py:1434
 msgid "Remove allocation"
 msgstr "Zuordnung entfernen"
 
@@ -2339,7 +2361,7 @@ msgstr "Notizen zum Stücklisten-Objekt"
 msgid "BOM line checksum"
 msgstr "Prüfsumme der Stückliste"
 
-#: part/models.py:1612 part/views.py:1349 part/views.py:1401
+#: part/models.py:1612 part/views.py:1351 part/views.py:1403
 #: stock/models.py:231
 #, fuzzy
 #| msgid "Overage must be an integer value or a percentage"
@@ -2410,7 +2432,7 @@ msgstr "Stückliste bearbeiten"
 msgid "Validate Bill of Materials"
 msgstr "Stückliste validieren"
 
-#: part/templates/part/bom.html:48 part/views.py:1640
+#: part/templates/part/bom.html:48 part/views.py:1642
 msgid "Export Bill of Materials"
 msgstr "Stückliste exportieren"
 
@@ -2526,7 +2548,7 @@ msgstr "Neuen Bau beginnen"
 msgid "All parts"
 msgstr "Alle Teile"
 
-#: part/templates/part/category.html:24 part/views.py:2043
+#: part/templates/part/category.html:24 part/views.py:2045
 msgid "Create new part category"
 msgstr "Teilkategorie anlegen"
 
@@ -2570,7 +2592,7 @@ msgstr "Teile (inklusive Unter-Kategorien)"
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:114 part/views.py:511
+#: part/templates/part/category.html:114 part/views.py:513
 msgid "Create new part"
 msgstr "Neues Teil anlegen"
 
@@ -2815,7 +2837,7 @@ msgid "Edit"
 msgstr "Bearbeiten"
 
 #: part/templates/part/params.html:39 part/templates/part/supplier.html:17
-#: users/models.py:141
+#: users/models.py:145
 msgid "Delete"
 msgstr "Löschen"
 
@@ -3051,184 +3073,184 @@ msgstr "Neues Teil hinzufügen"
 msgid "New Variant"
 msgstr "Varianten"
 
-#: part/views.py:78
+#: part/views.py:80
 msgid "Add part attachment"
 msgstr "Teilanhang hinzufügen"
 
-#: part/views.py:129 templates/attachment_table.html:30
+#: part/views.py:131 templates/attachment_table.html:30
 msgid "Edit attachment"
 msgstr "Anhang bearbeiten"
 
-#: part/views.py:135
+#: part/views.py:137
 msgid "Part attachment updated"
 msgstr "Teilanhang aktualisiert"
 
-#: part/views.py:150
+#: part/views.py:152
 msgid "Delete Part Attachment"
 msgstr "Teilanhang löschen"
 
-#: part/views.py:158
+#: part/views.py:160
 msgid "Deleted part attachment"
 msgstr "Teilanhang gelöscht"
 
-#: part/views.py:167
+#: part/views.py:169
 #, fuzzy
 #| msgid "Create Part Parameter Template"
 msgid "Create Test Template"
 msgstr "Teilparametervorlage anlegen"
 
-#: part/views.py:196
+#: part/views.py:198
 #, fuzzy
 #| msgid "Edit Template"
 msgid "Edit Test Template"
 msgstr "Vorlage bearbeiten"
 
-#: part/views.py:212
+#: part/views.py:214
 #, fuzzy
 #| msgid "Delete Template"
 msgid "Delete Test Template"
 msgstr "Vorlage löschen"
 
-#: part/views.py:221
+#: part/views.py:223
 msgid "Set Part Category"
 msgstr "Teilkategorie auswählen"
 
-#: part/views.py:271
+#: part/views.py:273
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr "Kategorie für {n} Teile setzen"
 
-#: part/views.py:306
+#: part/views.py:308
 msgid "Create Variant"
 msgstr "Variante anlegen"
 
-#: part/views.py:386
+#: part/views.py:388
 msgid "Duplicate Part"
 msgstr "Teil duplizieren"
 
-#: part/views.py:393
+#: part/views.py:395
 msgid "Copied part"
 msgstr "Teil kopiert"
 
-#: part/views.py:518
+#: part/views.py:520
 msgid "Created new part"
 msgstr "Neues Teil angelegt"
 
-#: part/views.py:733
+#: part/views.py:735
 msgid "Part QR Code"
 msgstr "Teil-QR-Code"
 
-#: part/views.py:752
+#: part/views.py:754
 msgid "Upload Part Image"
 msgstr "Teilbild hochladen"
 
-#: part/views.py:760 part/views.py:797
+#: part/views.py:762 part/views.py:799
 msgid "Updated part image"
 msgstr "Teilbild aktualisiert"
 
-#: part/views.py:769
+#: part/views.py:771
 msgid "Select Part Image"
 msgstr "Teilbild auswählen"
 
-#: part/views.py:800
+#: part/views.py:802
 msgid "Part image not found"
 msgstr "Teilbild nicht gefunden"
 
-#: part/views.py:811
+#: part/views.py:813
 msgid "Edit Part Properties"
 msgstr "Teileigenschaften bearbeiten"
 
-#: part/views.py:835
+#: part/views.py:837
 msgid "Validate BOM"
 msgstr "BOM validieren"
 
-#: part/views.py:1002
+#: part/views.py:1004
 msgid "No BOM file provided"
 msgstr "Keine Stückliste angegeben"
 
-#: part/views.py:1352
+#: part/views.py:1354
 msgid "Enter a valid quantity"
 msgstr "Bitte eine gültige Anzahl eingeben"
 
-#: part/views.py:1377 part/views.py:1380
+#: part/views.py:1379 part/views.py:1382
 msgid "Select valid part"
 msgstr "Bitte ein gültiges Teil auswählen"
 
-#: part/views.py:1386
+#: part/views.py:1388
 msgid "Duplicate part selected"
 msgstr "Teil doppelt ausgewählt"
 
-#: part/views.py:1424
+#: part/views.py:1426
 msgid "Select a part"
 msgstr "Teil auswählen"
 
-#: part/views.py:1430
+#: part/views.py:1432
 #, fuzzy
 #| msgid "Select part to be used in BOM"
 msgid "Selected part creates a circular BOM"
 msgstr "Teil für die Nutzung in der Stückliste auswählen"
 
-#: part/views.py:1434
+#: part/views.py:1436
 msgid "Specify quantity"
 msgstr "Anzahl angeben"
 
-#: part/views.py:1690
+#: part/views.py:1692
 msgid "Confirm Part Deletion"
 msgstr "Löschen des Teils bestätigen"
 
-#: part/views.py:1699
+#: part/views.py:1701
 msgid "Part was deleted"
 msgstr "Teil wurde gelöscht"
 
-#: part/views.py:1708
+#: part/views.py:1710
 msgid "Part Pricing"
 msgstr "Teilbepreisung"
 
-#: part/views.py:1834
+#: part/views.py:1836
 msgid "Create Part Parameter Template"
 msgstr "Teilparametervorlage anlegen"
 
-#: part/views.py:1844
+#: part/views.py:1846
 msgid "Edit Part Parameter Template"
 msgstr "Teilparametervorlage bearbeiten"
 
-#: part/views.py:1853
+#: part/views.py:1855
 msgid "Delete Part Parameter Template"
 msgstr "Teilparametervorlage löschen"
 
-#: part/views.py:1863
+#: part/views.py:1865
 msgid "Create Part Parameter"
 msgstr "Teilparameter anlegen"
 
-#: part/views.py:1915
+#: part/views.py:1917
 msgid "Edit Part Parameter"
 msgstr "Teilparameter bearbeiten"
 
-#: part/views.py:1931
+#: part/views.py:1933
 msgid "Delete Part Parameter"
 msgstr "Teilparameter löschen"
 
-#: part/views.py:1990
+#: part/views.py:1992
 msgid "Edit Part Category"
 msgstr "Teilkategorie bearbeiten"
 
-#: part/views.py:2027
+#: part/views.py:2029
 msgid "Delete Part Category"
 msgstr "Teilkategorie löschen"
 
-#: part/views.py:2035
+#: part/views.py:2037
 msgid "Part category was deleted"
 msgstr "Teilekategorie wurde gelöscht"
 
-#: part/views.py:2098
+#: part/views.py:2100
 msgid "Create BOM item"
 msgstr "BOM-Position anlegen"
 
-#: part/views.py:2166
+#: part/views.py:2168
 msgid "Edit BOM item"
 msgstr "BOM-Position beaarbeiten"
 
-#: part/views.py:2216
+#: part/views.py:2218
 msgid "Confim BOM item deletion"
 msgstr "Löschung von BOM-Position bestätigen"
 
@@ -5009,76 +5031,84 @@ msgstr "Position löschen"
 msgid "Delete Stock"
 msgstr "Bestand löschen"
 
-#: users/admin.py:62
+#: users/admin.py:61
 #, fuzzy
 #| msgid "User"
 msgid "Users"
 msgstr "Benutzer"
 
-#: users/admin.py:63
+#: users/admin.py:62
 msgid "Select which users are assigned to this group"
 msgstr ""
 
-#: users/admin.py:124
+#: users/admin.py:120
 #, fuzzy
 #| msgid "External Link"
 msgid "Personal info"
 msgstr "Externer Link"
 
-#: users/admin.py:125
+#: users/admin.py:121
 #, fuzzy
 #| msgid "Revision"
 msgid "Permissions"
 msgstr "Revision"
 
-#: users/admin.py:128
+#: users/admin.py:124
 #, fuzzy
 #| msgid "Import BOM data"
 msgid "Important dates"
 msgstr "Stückliste importieren"
 
-#: users/models.py:124
+#: users/models.py:128
 msgid "Permission set"
 msgstr ""
 
-#: users/models.py:132
+#: users/models.py:136
 msgid "Group"
 msgstr ""
 
-#: users/models.py:135
+#: users/models.py:139
 msgid "View"
 msgstr ""
 
-#: users/models.py:135
+#: users/models.py:139
 msgid "Permission to view items"
 msgstr ""
 
-#: users/models.py:137
-#, fuzzy
-#| msgid "Created"
-msgid "Create"
-msgstr "Erstellt"
-
-#: users/models.py:137
-msgid "Permission to add items"
-msgstr ""
-
-#: users/models.py:139
-#, fuzzy
-#| msgid "Last Updated"
-msgid "Update"
-msgstr "Zuletzt aktualisiert"
-
-#: users/models.py:139
-msgid "Permissions to edit items"
-msgstr ""
-
 #: users/models.py:141
 #, fuzzy
+#| msgid "Address"
+msgid "Add"
+msgstr "Adresse"
+
+#: users/models.py:141
+msgid "Permission to add items"
+msgstr ""
+
+#: users/models.py:143
+msgid "Change"
+msgstr ""
+
+#: users/models.py:143
+msgid "Permissions to edit items"
+msgstr ""
+
+#: users/models.py:145
+#, fuzzy
 #| msgid "Remove selected BOM items"
 msgid "Permission to delete items"
 msgstr "Ausgewählte Stücklistenpositionen entfernen"
 
+#, fuzzy
+#~| msgid "Created"
+#~ msgid "Create"
+#~ msgstr "Erstellt"
+
+#, fuzzy
+#~| msgid "Last Updated"
+#~ msgid "Update"
+#~ msgstr "Zuletzt aktualisiert"
+
 #~ msgid "Belongs To"
 #~ msgstr "Gehört zu"
 
diff --git a/InvenTree/locale/en/LC_MESSAGES/django.po b/InvenTree/locale/en/LC_MESSAGES/django.po
index 0f38022f92..74b435c791 100644
--- a/InvenTree/locale/en/LC_MESSAGES/django.po
+++ b/InvenTree/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-10-05 13:20+0000\n"
+"POT-Creation-Date: 2020-10-06 09:31+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,11 +18,11 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: InvenTree/api.py:83
+#: InvenTree/api.py:85
 msgid "No action specified"
 msgstr ""
 
-#: InvenTree/api.py:97
+#: InvenTree/api.py:99
 msgid "No matching action found"
 msgstr ""
 
@@ -46,34 +46,34 @@ msgstr ""
 msgid "Apply Theme"
 msgstr ""
 
-#: InvenTree/helpers.py:337 order/models.py:187 order/models.py:261
+#: InvenTree/helpers.py:339 order/models.py:187 order/models.py:261
 msgid "Invalid quantity provided"
 msgstr ""
 
-#: InvenTree/helpers.py:340
+#: InvenTree/helpers.py:342
 msgid "Empty serial number string"
 msgstr ""
 
-#: InvenTree/helpers.py:361
+#: InvenTree/helpers.py:363
 #, python-brace-format
 msgid "Duplicate serial: {n}"
 msgstr ""
 
-#: InvenTree/helpers.py:365 InvenTree/helpers.py:368 InvenTree/helpers.py:371
+#: InvenTree/helpers.py:367 InvenTree/helpers.py:370 InvenTree/helpers.py:373
 #, python-brace-format
 msgid "Invalid group: {g}"
 msgstr ""
 
-#: InvenTree/helpers.py:376
+#: InvenTree/helpers.py:378
 #, python-brace-format
 msgid "Duplicate serial: {g}"
 msgstr ""
 
-#: InvenTree/helpers.py:384
+#: InvenTree/helpers.py:386
 msgid "No serial numbers found"
 msgstr ""
 
-#: InvenTree/helpers.py:388
+#: InvenTree/helpers.py:390
 #, python-brace-format
 msgid "Number of unique serial number ({s}) must match quantity ({q})"
 msgstr ""
@@ -99,19 +99,19 @@ msgstr ""
 msgid "Description (optional)"
 msgstr ""
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:348
 msgid "English"
 msgstr ""
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:349
 msgid "German"
 msgstr ""
 
-#: InvenTree/settings.py:345
+#: InvenTree/settings.py:350
 msgid "French"
 msgstr ""
 
-#: InvenTree/settings.py:346
+#: InvenTree/settings.py:351
 msgid "Polish"
 msgstr ""
 
@@ -144,7 +144,7 @@ msgid "Returned"
 msgstr ""
 
 #: InvenTree/status_codes.py:136
-#: order/templates/order/sales_order_base.html:103
+#: order/templates/order/sales_order_base.html:105
 msgid "Shipped"
 msgstr ""
 
@@ -199,7 +199,7 @@ msgstr ""
 msgid "Overage must be an integer value or a percentage"
 msgstr ""
 
-#: InvenTree/views.py:661
+#: InvenTree/views.py:703
 msgid "Database Statistics"
 msgstr ""
 
@@ -263,7 +263,7 @@ msgstr ""
 msgid "Build quantity must be integer value for trackable parts"
 msgstr ""
 
-#: build/models.py:73 build/templates/build/build_base.html:70
+#: build/models.py:73 build/templates/build/build_base.html:72
 msgid "Build Title"
 msgstr ""
 
@@ -271,7 +271,7 @@ msgstr ""
 msgid "Brief description of the build"
 msgstr ""
 
-#: build/models.py:84 build/templates/build/build_base.html:91
+#: build/models.py:84 build/templates/build/build_base.html:93
 msgid "Parent Build"
 msgstr ""
 
@@ -281,7 +281,7 @@ msgstr ""
 
 #: build/models.py:90 build/templates/build/allocate.html:329
 #: build/templates/build/auto_allocate.html:19
-#: build/templates/build/build_base.html:75
+#: build/templates/build/build_base.html:77
 #: build/templates/build/detail.html:22 order/models.py:501
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:147
@@ -403,7 +403,7 @@ msgid "Stock quantity to allocate to build"
 msgstr ""
 
 #: build/templates/build/allocate.html:17
-#: company/templates/company/detail_part.html:18 order/views.py:779
+#: company/templates/company/detail_part.html:18 order/views.py:804
 #: part/templates/part/category.html:122
 msgid "Order Parts"
 msgstr ""
@@ -437,7 +437,7 @@ msgstr ""
 
 #: build/templates/build/allocate.html:172
 #: build/templates/build/auto_allocate.html:20
-#: build/templates/build/build_base.html:80
+#: build/templates/build/build_base.html:82
 #: build/templates/build/detail.html:27
 #: company/templates/company/supplier_part_pricing.html:71
 #: order/templates/order/order_wizard/select_parts.html:32
@@ -570,11 +570,27 @@ msgstr ""
 msgid "Admin view"
 msgstr ""
 
-#: build/templates/build/build_base.html:66 build/templates/build/detail.html:9
+#: build/templates/build/build_base.html:45
+msgid "Edit Build"
+msgstr ""
+
+#: build/templates/build/build_base.html:49 build/views.py:190
+msgid "Complete Build"
+msgstr ""
+
+#: build/templates/build/build_base.html:52 build/views.py:58
+msgid "Cancel Build"
+msgstr ""
+
+#: build/templates/build/build_base.html:58 build/views.py:454
+msgid "Delete Build"
+msgstr ""
+
+#: build/templates/build/build_base.html:68 build/templates/build/detail.html:9
 msgid "Build Details"
 msgstr ""
 
-#: build/templates/build/build_base.html:85
+#: build/templates/build/build_base.html:87
 #: build/templates/build/detail.html:42
 #: order/templates/order/receive_parts.html:24
 #: stock/templates/stock/item_base.html:276 templates/InvenTree/search.html:175
@@ -584,7 +600,7 @@ msgstr ""
 msgid "Status"
 msgstr ""
 
-#: build/templates/build/build_base.html:98 order/models.py:499
+#: build/templates/build/build_base.html:100 order/models.py:499
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
@@ -594,15 +610,15 @@ msgstr ""
 msgid "Sales Order"
 msgstr ""
 
-#: build/templates/build/build_base.html:104
+#: build/templates/build/build_base.html:106
 msgid "BOM Price"
 msgstr ""
 
-#: build/templates/build/build_base.html:109
+#: build/templates/build/build_base.html:111
 msgid "BOM pricing is incomplete"
 msgstr ""
 
-#: build/templates/build/build_base.html:112
+#: build/templates/build/build_base.html:114
 msgid "No pricing information"
 msgstr ""
 
@@ -664,8 +680,8 @@ msgid "Batch"
 msgstr ""
 
 #: build/templates/build/detail.html:61
-#: order/templates/order/order_base.html:98
-#: order/templates/order/sales_order_base.html:97 templates/js/build.html:71
+#: order/templates/order/order_base.html:100
+#: order/templates/order/sales_order_base.html:99 templates/js/build.html:71
 msgid "Created"
 msgstr ""
 
@@ -707,7 +723,7 @@ msgid "Save"
 msgstr ""
 
 #: build/templates/build/notes.html:33 company/templates/company/notes.html:30
-#: order/templates/order/order_notes.html:32
+#: order/templates/order/order_notes.html:33
 #: order/templates/order/sales_order_notes.html:37
 #: part/templates/part/notes.html:33 stock/templates/stock/item_notes.html:33
 msgid "Edit notes"
@@ -726,100 +742,88 @@ msgstr ""
 msgid "Are you sure you wish to unallocate all stock for this build?"
 msgstr ""
 
-#: build/views.py:56
-msgid "Cancel Build"
-msgstr ""
-
-#: build/views.py:74
+#: build/views.py:77
 msgid "Confirm build cancellation"
 msgstr ""
 
-#: build/views.py:79
+#: build/views.py:82
 msgid "Build was cancelled"
 msgstr ""
 
-#: build/views.py:95
+#: build/views.py:98
 msgid "Allocate Stock"
 msgstr ""
 
-#: build/views.py:108
+#: build/views.py:112
 msgid "No matching build found"
 msgstr ""
 
-#: build/views.py:127
+#: build/views.py:131
 msgid "Confirm stock allocation"
 msgstr ""
 
-#: build/views.py:128
+#: build/views.py:132
 msgid "Check the confirmation box at the bottom of the list"
 msgstr ""
 
-#: build/views.py:148 build/views.py:452
+#: build/views.py:152 build/views.py:465
 msgid "Unallocate Stock"
 msgstr ""
 
-#: build/views.py:161
+#: build/views.py:166
 msgid "Confirm unallocation of build stock"
 msgstr ""
 
-#: build/views.py:162 stock/views.py:405
+#: build/views.py:167 stock/views.py:405
 msgid "Check the confirmation box"
 msgstr ""
 
-#: build/views.py:185
-msgid "Complete Build"
-msgstr ""
-
-#: build/views.py:264
+#: build/views.py:270
 msgid "Confirm completion of build"
 msgstr ""
 
-#: build/views.py:271
+#: build/views.py:277
 msgid "Invalid location selected"
 msgstr ""
 
-#: build/views.py:296 stock/views.py:1621
+#: build/views.py:302 stock/views.py:1621
 #, python-brace-format
 msgid "The following serial numbers already exist: ({sn})"
 msgstr ""
 
-#: build/views.py:317
+#: build/views.py:323
 msgid "Build marked as COMPLETE"
 msgstr ""
 
-#: build/views.py:393
+#: build/views.py:403
 msgid "Start new Build"
 msgstr ""
 
-#: build/views.py:418
+#: build/views.py:429
 msgid "Created new build"
 msgstr ""
 
-#: build/views.py:428
+#: build/views.py:439
 msgid "Edit Build Details"
 msgstr ""
 
-#: build/views.py:433
+#: build/views.py:445
 msgid "Edited build"
 msgstr ""
 
-#: build/views.py:442
-msgid "Delete Build"
-msgstr ""
-
-#: build/views.py:457
+#: build/views.py:471
 msgid "Removed parts from build allocation"
 msgstr ""
 
-#: build/views.py:467
+#: build/views.py:481
 msgid "Allocate new Part"
 msgstr ""
 
-#: build/views.py:620
+#: build/views.py:635
 msgid "Edit Stock Allocation"
 msgstr ""
 
-#: build/views.py:624
+#: build/views.py:640
 msgid "Updated Build Item"
 msgstr ""
 
@@ -1014,7 +1018,7 @@ msgstr ""
 #: company/templates/company/detail.html:21
 #: company/templates/company/supplier_part_base.html:66
 #: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:79
+#: order/templates/order/order_base.html:81
 #: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
 #: stock/templates/stock/item_base.html:251 templates/js/company.html:48
 #: templates/js/company.html:162 templates/js/order.html:146
@@ -1022,7 +1026,7 @@ msgid "Supplier"
 msgstr ""
 
 #: company/templates/company/detail.html:26
-#: order/templates/order/sales_order_base.html:78 stock/models.py:370
+#: order/templates/order/sales_order_base.html:80 stock/models.py:370
 #: stock/models.py:371 stock/templates/stock/item_base.html:169
 #: templates/js/company.html:40 templates/js/order.html:221
 msgid "Customer"
@@ -1123,12 +1127,12 @@ msgid "Purchase Orders"
 msgstr ""
 
 #: company/templates/company/purchase_orders.html:14
-#: order/templates/order/purchase_orders.html:17
+#: order/templates/order/purchase_orders.html:18
 msgid "Create new purchase order"
 msgstr ""
 
 #: company/templates/company/purchase_orders.html:14
-#: order/templates/order/purchase_orders.html:17
+#: order/templates/order/purchase_orders.html:18
 msgid "New Purchase Order"
 msgstr ""
 
@@ -1142,12 +1146,12 @@ msgid "Sales Orders"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:14
-#: order/templates/order/sales_orders.html:17
+#: order/templates/order/sales_orders.html:18
 msgid "Create new sales order"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:14
-#: order/templates/order/sales_orders.html:17
+#: order/templates/order/sales_orders.html:18
 msgid "New Sales Order"
 msgstr ""
 
@@ -1205,7 +1209,7 @@ msgid "Pricing Information"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2226
+#: part/templates/part/sale_prices.html:13 part/views.py:2228
 msgid "Add Price Break"
 msgstr ""
 
@@ -1330,15 +1334,15 @@ msgstr ""
 msgid "Delete Supplier Part"
 msgstr ""
 
-#: company/views.py:404 part/views.py:2232
+#: company/views.py:404 part/views.py:2234
 msgid "Added new price break"
 msgstr ""
 
-#: company/views.py:441 part/views.py:2277
+#: company/views.py:441 part/views.py:2279
 msgid "Edit Price Break"
 msgstr ""
 
-#: company/views.py:456 part/views.py:2293
+#: company/views.py:456 part/views.py:2295
 msgid "Delete Price Break"
 msgstr ""
 
@@ -1366,20 +1370,20 @@ msgstr ""
 msgid "Enabled"
 msgstr ""
 
-#: order/forms.py:24
+#: order/forms.py:24 order/templates/order/order_base.html:40
 msgid "Place order"
 msgstr ""
 
-#: order/forms.py:35
+#: order/forms.py:35 order/templates/order/order_base.html:47
 msgid "Mark order as complete"
 msgstr ""
 
-#: order/forms.py:46 order/forms.py:57
-#: order/templates/order/sales_order_base.html:54
+#: order/forms.py:46 order/forms.py:57 order/templates/order/order_base.html:52
+#: order/templates/order/sales_order_base.html:52
 msgid "Cancel order"
 msgstr ""
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:51
+#: order/forms.py:68 order/templates/order/sales_order_base.html:49
 msgid "Ship order"
 msgstr ""
 
@@ -1431,7 +1435,7 @@ msgstr ""
 msgid "Date order was completed"
 msgstr ""
 
-#: order/models.py:185 order/models.py:259 part/views.py:1343
+#: order/models.py:185 order/models.py:259 part/views.py:1345
 #: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr ""
@@ -1512,32 +1516,44 @@ msgstr ""
 msgid "Are you sure you want to delete this attachment?"
 msgstr ""
 
-#: order/templates/order/order_base.html:64
+#: order/templates/order/order_base.html:36
+msgid "Edit order information"
+msgstr ""
+
+#: order/templates/order/order_base.html:44
+msgid "Receive items"
+msgstr ""
+
+#: order/templates/order/order_base.html:57
+msgid "Export order to file"
+msgstr ""
+
+#: order/templates/order/order_base.html:66
 msgid "Purchase Order Details"
 msgstr ""
 
-#: order/templates/order/order_base.html:69
-#: order/templates/order/sales_order_base.html:68
+#: order/templates/order/order_base.html:71
+#: order/templates/order/sales_order_base.html:70
 msgid "Order Reference"
 msgstr ""
 
-#: order/templates/order/order_base.html:74
-#: order/templates/order/sales_order_base.html:73
+#: order/templates/order/order_base.html:76
+#: order/templates/order/sales_order_base.html:75
 msgid "Order Status"
 msgstr ""
 
-#: order/templates/order/order_base.html:85 templates/js/order.html:153
+#: order/templates/order/order_base.html:87 templates/js/order.html:153
 msgid "Supplier Reference"
 msgstr ""
 
-#: order/templates/order/order_base.html:104
+#: order/templates/order/order_base.html:106
 msgid "Issued"
 msgstr ""
 
-#: order/templates/order/order_base.html:111
+#: order/templates/order/order_base.html:113
 #: order/templates/order/purchase_order_detail.html:182
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:110
+#: order/templates/order/sales_order_base.html:112
 msgid "Received"
 msgstr ""
 
@@ -1605,8 +1621,8 @@ msgid "Attachments"
 msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:16
-#: order/templates/order/sales_order_detail.html:17 order/views.py:1087
-#: order/views.py:1201
+#: order/templates/order/sales_order_detail.html:17 order/views.py:1117
+#: order/views.py:1232
 msgid "Add Line Item"
 msgstr ""
 
@@ -1674,15 +1690,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:47
+#: order/templates/order/sales_order_base.html:57
 msgid "Packing List"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:63
+#: order/templates/order/sales_order_base.html:65
 msgid "Sales Order Details"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:84 templates/js/order.html:228
+#: order/templates/order/sales_order_base.html:86 templates/js/order.html:228
 msgid "Customer Reference"
 msgstr ""
 
@@ -1746,147 +1762,147 @@ msgstr ""
 msgid "Order Items"
 msgstr ""
 
-#: order/views.py:93
+#: order/views.py:99
 msgid "Add Purchase Order Attachment"
 msgstr ""
 
-#: order/views.py:102 order/views.py:149 part/views.py:90 stock/views.py:167
+#: order/views.py:109 order/views.py:157 part/views.py:92 stock/views.py:167
 msgid "Added attachment"
 msgstr ""
 
-#: order/views.py:141
+#: order/views.py:148
 msgid "Add Sales Order Attachment"
 msgstr ""
 
-#: order/views.py:176 order/views.py:197
+#: order/views.py:184 order/views.py:206
 msgid "Edit Attachment"
 msgstr ""
 
-#: order/views.py:180 order/views.py:201
+#: order/views.py:189 order/views.py:211
 msgid "Attachment updated"
 msgstr ""
 
-#: order/views.py:216 order/views.py:230
+#: order/views.py:226 order/views.py:241
 msgid "Delete Attachment"
 msgstr ""
 
-#: order/views.py:222 order/views.py:236 stock/views.py:223
+#: order/views.py:233 order/views.py:248 stock/views.py:223
 msgid "Deleted attachment"
 msgstr ""
 
-#: order/views.py:287
+#: order/views.py:301
 msgid "Create Purchase Order"
 msgstr ""
 
-#: order/views.py:318
+#: order/views.py:333
 msgid "Create Sales Order"
 msgstr ""
 
-#: order/views.py:348
+#: order/views.py:364
 msgid "Edit Purchase Order"
 msgstr ""
 
-#: order/views.py:368
+#: order/views.py:385
 msgid "Edit Sales Order"
 msgstr ""
 
-#: order/views.py:384
+#: order/views.py:402
 msgid "Cancel Order"
 msgstr ""
 
-#: order/views.py:399 order/views.py:431
+#: order/views.py:418 order/views.py:451
 msgid "Confirm order cancellation"
 msgstr ""
 
-#: order/views.py:417
+#: order/views.py:436
 msgid "Cancel sales order"
 msgstr ""
 
-#: order/views.py:437
+#: order/views.py:457
 msgid "Could not cancel order"
 msgstr ""
 
-#: order/views.py:451
+#: order/views.py:471
 msgid "Issue Order"
 msgstr ""
 
-#: order/views.py:466
+#: order/views.py:487
 msgid "Confirm order placement"
 msgstr ""
 
-#: order/views.py:487
+#: order/views.py:508
 msgid "Complete Order"
 msgstr ""
 
-#: order/views.py:522
+#: order/views.py:544
 msgid "Ship Order"
 msgstr ""
 
-#: order/views.py:538
+#: order/views.py:561
 msgid "Confirm order shipment"
 msgstr ""
 
-#: order/views.py:544
+#: order/views.py:567
 msgid "Could not ship order"
 msgstr ""
 
-#: order/views.py:595
+#: order/views.py:619
 msgid "Receive Parts"
 msgstr ""
 
-#: order/views.py:662
+#: order/views.py:687
 msgid "Items received"
 msgstr ""
 
-#: order/views.py:676
+#: order/views.py:701
 msgid "No destination set"
 msgstr ""
 
-#: order/views.py:721
+#: order/views.py:746
 msgid "Error converting quantity to number"
 msgstr ""
 
-#: order/views.py:727
+#: order/views.py:752
 msgid "Receive quantity less than zero"
 msgstr ""
 
-#: order/views.py:733
+#: order/views.py:758
 msgid "No lines specified"
 msgstr ""
 
-#: order/views.py:1107
+#: order/views.py:1138
 msgid "Invalid Purchase Order"
 msgstr ""
 
-#: order/views.py:1115
+#: order/views.py:1146
 msgid "Supplier must match for Part and Order"
 msgstr ""
 
-#: order/views.py:1120
+#: order/views.py:1151
 msgid "Invalid SupplierPart selection"
 msgstr ""
 
-#: order/views.py:1252 order/views.py:1270
+#: order/views.py:1284 order/views.py:1303
 msgid "Edit Line Item"
 msgstr ""
 
-#: order/views.py:1286 order/views.py:1298
+#: order/views.py:1320 order/views.py:1333
 msgid "Delete Line Item"
 msgstr ""
 
-#: order/views.py:1291 order/views.py:1303
+#: order/views.py:1326 order/views.py:1339
 msgid "Deleted line item"
 msgstr ""
 
-#: order/views.py:1312
+#: order/views.py:1348
 msgid "Allocate Stock to Order"
 msgstr ""
 
-#: order/views.py:1381
+#: order/views.py:1418
 msgid "Edit Allocation Quantity"
 msgstr ""
 
-#: order/views.py:1396
+#: order/views.py:1434
 msgid "Remove allocation"
 msgstr ""
 
@@ -2226,7 +2242,7 @@ msgstr ""
 msgid "BOM line checksum"
 msgstr ""
 
-#: part/models.py:1612 part/views.py:1349 part/views.py:1401
+#: part/models.py:1612 part/views.py:1351 part/views.py:1403
 #: stock/models.py:231
 msgid "Quantity must be integer value for trackable parts"
 msgstr ""
@@ -2293,7 +2309,7 @@ msgstr ""
 msgid "Validate Bill of Materials"
 msgstr ""
 
-#: part/templates/part/bom.html:48 part/views.py:1640
+#: part/templates/part/bom.html:48 part/views.py:1642
 msgid "Export Bill of Materials"
 msgstr ""
 
@@ -2385,7 +2401,7 @@ msgstr ""
 msgid "All parts"
 msgstr ""
 
-#: part/templates/part/category.html:24 part/views.py:2043
+#: part/templates/part/category.html:24 part/views.py:2045
 msgid "Create new part category"
 msgstr ""
 
@@ -2425,7 +2441,7 @@ msgstr ""
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:114 part/views.py:511
+#: part/templates/part/category.html:114 part/views.py:513
 msgid "Create new part"
 msgstr ""
 
@@ -2642,7 +2658,7 @@ msgid "Edit"
 msgstr ""
 
 #: part/templates/part/params.html:39 part/templates/part/supplier.html:17
-#: users/models.py:141
+#: users/models.py:145
 msgid "Delete"
 msgstr ""
 
@@ -2842,176 +2858,176 @@ msgstr ""
 msgid "New Variant"
 msgstr ""
 
-#: part/views.py:78
+#: part/views.py:80
 msgid "Add part attachment"
 msgstr ""
 
-#: part/views.py:129 templates/attachment_table.html:30
+#: part/views.py:131 templates/attachment_table.html:30
 msgid "Edit attachment"
 msgstr ""
 
-#: part/views.py:135
+#: part/views.py:137
 msgid "Part attachment updated"
 msgstr ""
 
-#: part/views.py:150
+#: part/views.py:152
 msgid "Delete Part Attachment"
 msgstr ""
 
-#: part/views.py:158
+#: part/views.py:160
 msgid "Deleted part attachment"
 msgstr ""
 
-#: part/views.py:167
+#: part/views.py:169
 msgid "Create Test Template"
 msgstr ""
 
-#: part/views.py:196
+#: part/views.py:198
 msgid "Edit Test Template"
 msgstr ""
 
-#: part/views.py:212
+#: part/views.py:214
 msgid "Delete Test Template"
 msgstr ""
 
-#: part/views.py:221
+#: part/views.py:223
 msgid "Set Part Category"
 msgstr ""
 
-#: part/views.py:271
+#: part/views.py:273
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr ""
 
-#: part/views.py:306
+#: part/views.py:308
 msgid "Create Variant"
 msgstr ""
 
-#: part/views.py:386
+#: part/views.py:388
 msgid "Duplicate Part"
 msgstr ""
 
-#: part/views.py:393
+#: part/views.py:395
 msgid "Copied part"
 msgstr ""
 
-#: part/views.py:518
+#: part/views.py:520
 msgid "Created new part"
 msgstr ""
 
-#: part/views.py:733
+#: part/views.py:735
 msgid "Part QR Code"
 msgstr ""
 
-#: part/views.py:752
+#: part/views.py:754
 msgid "Upload Part Image"
 msgstr ""
 
-#: part/views.py:760 part/views.py:797
+#: part/views.py:762 part/views.py:799
 msgid "Updated part image"
 msgstr ""
 
-#: part/views.py:769
+#: part/views.py:771
 msgid "Select Part Image"
 msgstr ""
 
-#: part/views.py:800
+#: part/views.py:802
 msgid "Part image not found"
 msgstr ""
 
-#: part/views.py:811
+#: part/views.py:813
 msgid "Edit Part Properties"
 msgstr ""
 
-#: part/views.py:835
+#: part/views.py:837
 msgid "Validate BOM"
 msgstr ""
 
-#: part/views.py:1002
+#: part/views.py:1004
 msgid "No BOM file provided"
 msgstr ""
 
-#: part/views.py:1352
+#: part/views.py:1354
 msgid "Enter a valid quantity"
 msgstr ""
 
-#: part/views.py:1377 part/views.py:1380
+#: part/views.py:1379 part/views.py:1382
 msgid "Select valid part"
 msgstr ""
 
-#: part/views.py:1386
+#: part/views.py:1388
 msgid "Duplicate part selected"
 msgstr ""
 
-#: part/views.py:1424
+#: part/views.py:1426
 msgid "Select a part"
 msgstr ""
 
-#: part/views.py:1430
+#: part/views.py:1432
 msgid "Selected part creates a circular BOM"
 msgstr ""
 
-#: part/views.py:1434
+#: part/views.py:1436
 msgid "Specify quantity"
 msgstr ""
 
-#: part/views.py:1690
+#: part/views.py:1692
 msgid "Confirm Part Deletion"
 msgstr ""
 
-#: part/views.py:1699
+#: part/views.py:1701
 msgid "Part was deleted"
 msgstr ""
 
-#: part/views.py:1708
+#: part/views.py:1710
 msgid "Part Pricing"
 msgstr ""
 
-#: part/views.py:1834
+#: part/views.py:1836
 msgid "Create Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1844
+#: part/views.py:1846
 msgid "Edit Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1853
+#: part/views.py:1855
 msgid "Delete Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1863
+#: part/views.py:1865
 msgid "Create Part Parameter"
 msgstr ""
 
-#: part/views.py:1915
+#: part/views.py:1917
 msgid "Edit Part Parameter"
 msgstr ""
 
-#: part/views.py:1931
+#: part/views.py:1933
 msgid "Delete Part Parameter"
 msgstr ""
 
-#: part/views.py:1990
+#: part/views.py:1992
 msgid "Edit Part Category"
 msgstr ""
 
-#: part/views.py:2027
+#: part/views.py:2029
 msgid "Delete Part Category"
 msgstr ""
 
-#: part/views.py:2035
+#: part/views.py:2037
 msgid "Part category was deleted"
 msgstr ""
 
-#: part/views.py:2098
+#: part/views.py:2100
 msgid "Create BOM item"
 msgstr ""
 
-#: part/views.py:2166
+#: part/views.py:2168
 msgid "Edit BOM item"
 msgstr ""
 
-#: part/views.py:2216
+#: part/views.py:2218
 msgid "Confim BOM item deletion"
 msgstr ""
 
@@ -4497,58 +4513,58 @@ msgstr ""
 msgid "Delete Stock"
 msgstr ""
 
-#: users/admin.py:62
+#: users/admin.py:61
 msgid "Users"
 msgstr ""
 
-#: users/admin.py:63
+#: users/admin.py:62
 msgid "Select which users are assigned to this group"
 msgstr ""
 
-#: users/admin.py:124
+#: users/admin.py:120
 msgid "Personal info"
 msgstr ""
 
-#: users/admin.py:125
+#: users/admin.py:121
 msgid "Permissions"
 msgstr ""
 
-#: users/admin.py:128
+#: users/admin.py:124
 msgid "Important dates"
 msgstr ""
 
-#: users/models.py:124
+#: users/models.py:128
 msgid "Permission set"
 msgstr ""
 
-#: users/models.py:132
+#: users/models.py:136
 msgid "Group"
 msgstr ""
 
-#: users/models.py:135
+#: users/models.py:139
 msgid "View"
 msgstr ""
 
-#: users/models.py:135
+#: users/models.py:139
 msgid "Permission to view items"
 msgstr ""
 
-#: users/models.py:137
-msgid "Create"
-msgstr ""
-
-#: users/models.py:137
-msgid "Permission to add items"
-msgstr ""
-
-#: users/models.py:139
-msgid "Update"
-msgstr ""
-
-#: users/models.py:139
-msgid "Permissions to edit items"
+#: users/models.py:141
+msgid "Add"
 msgstr ""
 
 #: users/models.py:141
+msgid "Permission to add items"
+msgstr ""
+
+#: users/models.py:143
+msgid "Change"
+msgstr ""
+
+#: users/models.py:143
+msgid "Permissions to edit items"
+msgstr ""
+
+#: users/models.py:145
 msgid "Permission to delete items"
 msgstr ""
diff --git a/InvenTree/locale/es/LC_MESSAGES/django.po b/InvenTree/locale/es/LC_MESSAGES/django.po
index 0f38022f92..74b435c791 100644
--- a/InvenTree/locale/es/LC_MESSAGES/django.po
+++ b/InvenTree/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-10-05 13:20+0000\n"
+"POT-Creation-Date: 2020-10-06 09:31+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,11 +18,11 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 
-#: InvenTree/api.py:83
+#: InvenTree/api.py:85
 msgid "No action specified"
 msgstr ""
 
-#: InvenTree/api.py:97
+#: InvenTree/api.py:99
 msgid "No matching action found"
 msgstr ""
 
@@ -46,34 +46,34 @@ msgstr ""
 msgid "Apply Theme"
 msgstr ""
 
-#: InvenTree/helpers.py:337 order/models.py:187 order/models.py:261
+#: InvenTree/helpers.py:339 order/models.py:187 order/models.py:261
 msgid "Invalid quantity provided"
 msgstr ""
 
-#: InvenTree/helpers.py:340
+#: InvenTree/helpers.py:342
 msgid "Empty serial number string"
 msgstr ""
 
-#: InvenTree/helpers.py:361
+#: InvenTree/helpers.py:363
 #, python-brace-format
 msgid "Duplicate serial: {n}"
 msgstr ""
 
-#: InvenTree/helpers.py:365 InvenTree/helpers.py:368 InvenTree/helpers.py:371
+#: InvenTree/helpers.py:367 InvenTree/helpers.py:370 InvenTree/helpers.py:373
 #, python-brace-format
 msgid "Invalid group: {g}"
 msgstr ""
 
-#: InvenTree/helpers.py:376
+#: InvenTree/helpers.py:378
 #, python-brace-format
 msgid "Duplicate serial: {g}"
 msgstr ""
 
-#: InvenTree/helpers.py:384
+#: InvenTree/helpers.py:386
 msgid "No serial numbers found"
 msgstr ""
 
-#: InvenTree/helpers.py:388
+#: InvenTree/helpers.py:390
 #, python-brace-format
 msgid "Number of unique serial number ({s}) must match quantity ({q})"
 msgstr ""
@@ -99,19 +99,19 @@ msgstr ""
 msgid "Description (optional)"
 msgstr ""
 
-#: InvenTree/settings.py:343
+#: InvenTree/settings.py:348
 msgid "English"
 msgstr ""
 
-#: InvenTree/settings.py:344
+#: InvenTree/settings.py:349
 msgid "German"
 msgstr ""
 
-#: InvenTree/settings.py:345
+#: InvenTree/settings.py:350
 msgid "French"
 msgstr ""
 
-#: InvenTree/settings.py:346
+#: InvenTree/settings.py:351
 msgid "Polish"
 msgstr ""
 
@@ -144,7 +144,7 @@ msgid "Returned"
 msgstr ""
 
 #: InvenTree/status_codes.py:136
-#: order/templates/order/sales_order_base.html:103
+#: order/templates/order/sales_order_base.html:105
 msgid "Shipped"
 msgstr ""
 
@@ -199,7 +199,7 @@ msgstr ""
 msgid "Overage must be an integer value or a percentage"
 msgstr ""
 
-#: InvenTree/views.py:661
+#: InvenTree/views.py:703
 msgid "Database Statistics"
 msgstr ""
 
@@ -263,7 +263,7 @@ msgstr ""
 msgid "Build quantity must be integer value for trackable parts"
 msgstr ""
 
-#: build/models.py:73 build/templates/build/build_base.html:70
+#: build/models.py:73 build/templates/build/build_base.html:72
 msgid "Build Title"
 msgstr ""
 
@@ -271,7 +271,7 @@ msgstr ""
 msgid "Brief description of the build"
 msgstr ""
 
-#: build/models.py:84 build/templates/build/build_base.html:91
+#: build/models.py:84 build/templates/build/build_base.html:93
 msgid "Parent Build"
 msgstr ""
 
@@ -281,7 +281,7 @@ msgstr ""
 
 #: build/models.py:90 build/templates/build/allocate.html:329
 #: build/templates/build/auto_allocate.html:19
-#: build/templates/build/build_base.html:75
+#: build/templates/build/build_base.html:77
 #: build/templates/build/detail.html:22 order/models.py:501
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:147
@@ -403,7 +403,7 @@ msgid "Stock quantity to allocate to build"
 msgstr ""
 
 #: build/templates/build/allocate.html:17
-#: company/templates/company/detail_part.html:18 order/views.py:779
+#: company/templates/company/detail_part.html:18 order/views.py:804
 #: part/templates/part/category.html:122
 msgid "Order Parts"
 msgstr ""
@@ -437,7 +437,7 @@ msgstr ""
 
 #: build/templates/build/allocate.html:172
 #: build/templates/build/auto_allocate.html:20
-#: build/templates/build/build_base.html:80
+#: build/templates/build/build_base.html:82
 #: build/templates/build/detail.html:27
 #: company/templates/company/supplier_part_pricing.html:71
 #: order/templates/order/order_wizard/select_parts.html:32
@@ -570,11 +570,27 @@ msgstr ""
 msgid "Admin view"
 msgstr ""
 
-#: build/templates/build/build_base.html:66 build/templates/build/detail.html:9
+#: build/templates/build/build_base.html:45
+msgid "Edit Build"
+msgstr ""
+
+#: build/templates/build/build_base.html:49 build/views.py:190
+msgid "Complete Build"
+msgstr ""
+
+#: build/templates/build/build_base.html:52 build/views.py:58
+msgid "Cancel Build"
+msgstr ""
+
+#: build/templates/build/build_base.html:58 build/views.py:454
+msgid "Delete Build"
+msgstr ""
+
+#: build/templates/build/build_base.html:68 build/templates/build/detail.html:9
 msgid "Build Details"
 msgstr ""
 
-#: build/templates/build/build_base.html:85
+#: build/templates/build/build_base.html:87
 #: build/templates/build/detail.html:42
 #: order/templates/order/receive_parts.html:24
 #: stock/templates/stock/item_base.html:276 templates/InvenTree/search.html:175
@@ -584,7 +600,7 @@ msgstr ""
 msgid "Status"
 msgstr ""
 
-#: build/templates/build/build_base.html:98 order/models.py:499
+#: build/templates/build/build_base.html:100 order/models.py:499
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
@@ -594,15 +610,15 @@ msgstr ""
 msgid "Sales Order"
 msgstr ""
 
-#: build/templates/build/build_base.html:104
+#: build/templates/build/build_base.html:106
 msgid "BOM Price"
 msgstr ""
 
-#: build/templates/build/build_base.html:109
+#: build/templates/build/build_base.html:111
 msgid "BOM pricing is incomplete"
 msgstr ""
 
-#: build/templates/build/build_base.html:112
+#: build/templates/build/build_base.html:114
 msgid "No pricing information"
 msgstr ""
 
@@ -664,8 +680,8 @@ msgid "Batch"
 msgstr ""
 
 #: build/templates/build/detail.html:61
-#: order/templates/order/order_base.html:98
-#: order/templates/order/sales_order_base.html:97 templates/js/build.html:71
+#: order/templates/order/order_base.html:100
+#: order/templates/order/sales_order_base.html:99 templates/js/build.html:71
 msgid "Created"
 msgstr ""
 
@@ -707,7 +723,7 @@ msgid "Save"
 msgstr ""
 
 #: build/templates/build/notes.html:33 company/templates/company/notes.html:30
-#: order/templates/order/order_notes.html:32
+#: order/templates/order/order_notes.html:33
 #: order/templates/order/sales_order_notes.html:37
 #: part/templates/part/notes.html:33 stock/templates/stock/item_notes.html:33
 msgid "Edit notes"
@@ -726,100 +742,88 @@ msgstr ""
 msgid "Are you sure you wish to unallocate all stock for this build?"
 msgstr ""
 
-#: build/views.py:56
-msgid "Cancel Build"
-msgstr ""
-
-#: build/views.py:74
+#: build/views.py:77
 msgid "Confirm build cancellation"
 msgstr ""
 
-#: build/views.py:79
+#: build/views.py:82
 msgid "Build was cancelled"
 msgstr ""
 
-#: build/views.py:95
+#: build/views.py:98
 msgid "Allocate Stock"
 msgstr ""
 
-#: build/views.py:108
+#: build/views.py:112
 msgid "No matching build found"
 msgstr ""
 
-#: build/views.py:127
+#: build/views.py:131
 msgid "Confirm stock allocation"
 msgstr ""
 
-#: build/views.py:128
+#: build/views.py:132
 msgid "Check the confirmation box at the bottom of the list"
 msgstr ""
 
-#: build/views.py:148 build/views.py:452
+#: build/views.py:152 build/views.py:465
 msgid "Unallocate Stock"
 msgstr ""
 
-#: build/views.py:161
+#: build/views.py:166
 msgid "Confirm unallocation of build stock"
 msgstr ""
 
-#: build/views.py:162 stock/views.py:405
+#: build/views.py:167 stock/views.py:405
 msgid "Check the confirmation box"
 msgstr ""
 
-#: build/views.py:185
-msgid "Complete Build"
-msgstr ""
-
-#: build/views.py:264
+#: build/views.py:270
 msgid "Confirm completion of build"
 msgstr ""
 
-#: build/views.py:271
+#: build/views.py:277
 msgid "Invalid location selected"
 msgstr ""
 
-#: build/views.py:296 stock/views.py:1621
+#: build/views.py:302 stock/views.py:1621
 #, python-brace-format
 msgid "The following serial numbers already exist: ({sn})"
 msgstr ""
 
-#: build/views.py:317
+#: build/views.py:323
 msgid "Build marked as COMPLETE"
 msgstr ""
 
-#: build/views.py:393
+#: build/views.py:403
 msgid "Start new Build"
 msgstr ""
 
-#: build/views.py:418
+#: build/views.py:429
 msgid "Created new build"
 msgstr ""
 
-#: build/views.py:428
+#: build/views.py:439
 msgid "Edit Build Details"
 msgstr ""
 
-#: build/views.py:433
+#: build/views.py:445
 msgid "Edited build"
 msgstr ""
 
-#: build/views.py:442
-msgid "Delete Build"
-msgstr ""
-
-#: build/views.py:457
+#: build/views.py:471
 msgid "Removed parts from build allocation"
 msgstr ""
 
-#: build/views.py:467
+#: build/views.py:481
 msgid "Allocate new Part"
 msgstr ""
 
-#: build/views.py:620
+#: build/views.py:635
 msgid "Edit Stock Allocation"
 msgstr ""
 
-#: build/views.py:624
+#: build/views.py:640
 msgid "Updated Build Item"
 msgstr ""
 
@@ -1014,7 +1018,7 @@ msgstr ""
 #: company/templates/company/detail.html:21
 #: company/templates/company/supplier_part_base.html:66
 #: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:79
+#: order/templates/order/order_base.html:81
 #: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
 #: stock/templates/stock/item_base.html:251 templates/js/company.html:48
 #: templates/js/company.html:162 templates/js/order.html:146
@@ -1022,7 +1026,7 @@ msgid "Supplier"
 msgstr ""
 
 #: company/templates/company/detail.html:26
-#: order/templates/order/sales_order_base.html:78 stock/models.py:370
+#: order/templates/order/sales_order_base.html:80 stock/models.py:370
 #: stock/models.py:371 stock/templates/stock/item_base.html:169
 #: templates/js/company.html:40 templates/js/order.html:221
 msgid "Customer"
@@ -1123,12 +1127,12 @@ msgid "Purchase Orders"
 msgstr ""
 
 #: company/templates/company/purchase_orders.html:14
-#: order/templates/order/purchase_orders.html:17
+#: order/templates/order/purchase_orders.html:18
 msgid "Create new purchase order"
 msgstr ""
 
 #: company/templates/company/purchase_orders.html:14
-#: order/templates/order/purchase_orders.html:17
+#: order/templates/order/purchase_orders.html:18
 msgid "New Purchase Order"
 msgstr ""
 
@@ -1142,12 +1146,12 @@ msgid "Sales Orders"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:14
-#: order/templates/order/sales_orders.html:17
+#: order/templates/order/sales_orders.html:18
 msgid "Create new sales order"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:14
-#: order/templates/order/sales_orders.html:17
+#: order/templates/order/sales_orders.html:18
 msgid "New Sales Order"
 msgstr ""
 
@@ -1205,7 +1209,7 @@ msgid "Pricing Information"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:15 company/views.py:399
-#: part/templates/part/sale_prices.html:13 part/views.py:2226
+#: part/templates/part/sale_prices.html:13 part/views.py:2228
 msgid "Add Price Break"
 msgstr ""
 
@@ -1330,15 +1334,15 @@ msgstr ""
 msgid "Delete Supplier Part"
 msgstr ""
 
-#: company/views.py:404 part/views.py:2232
+#: company/views.py:404 part/views.py:2234
 msgid "Added new price break"
 msgstr ""
 
-#: company/views.py:441 part/views.py:2277
+#: company/views.py:441 part/views.py:2279
 msgid "Edit Price Break"
 msgstr ""
 
-#: company/views.py:456 part/views.py:2293
+#: company/views.py:456 part/views.py:2295
 msgid "Delete Price Break"
 msgstr ""
 
@@ -1366,20 +1370,20 @@ msgstr ""
 msgid "Enabled"
 msgstr ""
 
-#: order/forms.py:24
+#: order/forms.py:24 order/templates/order/order_base.html:40
 msgid "Place order"
 msgstr ""
 
-#: order/forms.py:35
+#: order/forms.py:35 order/templates/order/order_base.html:47
 msgid "Mark order as complete"
 msgstr ""
 
-#: order/forms.py:46 order/forms.py:57
-#: order/templates/order/sales_order_base.html:54
+#: order/forms.py:46 order/forms.py:57 order/templates/order/order_base.html:52
+#: order/templates/order/sales_order_base.html:52
 msgid "Cancel order"
 msgstr ""
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:51
+#: order/forms.py:68 order/templates/order/sales_order_base.html:49
 msgid "Ship order"
 msgstr ""
 
@@ -1431,7 +1435,7 @@ msgstr ""
 msgid "Date order was completed"
 msgstr ""
 
-#: order/models.py:185 order/models.py:259 part/views.py:1343
+#: order/models.py:185 order/models.py:259 part/views.py:1345
 #: stock/models.py:241 stock/models.py:805
 msgid "Quantity must be greater than zero"
 msgstr ""
@@ -1512,32 +1516,44 @@ msgstr ""
 msgid "Are you sure you want to delete this attachment?"
 msgstr ""
 
-#: order/templates/order/order_base.html:64
+#: order/templates/order/order_base.html:36
+msgid "Edit order information"
+msgstr ""
+
+#: order/templates/order/order_base.html:44
+msgid "Receive items"
+msgstr ""
+
+#: order/templates/order/order_base.html:57
+msgid "Export order to file"
+msgstr ""
+
+#: order/templates/order/order_base.html:66
 msgid "Purchase Order Details"
 msgstr ""
 
-#: order/templates/order/order_base.html:69
-#: order/templates/order/sales_order_base.html:68
+#: order/templates/order/order_base.html:71
+#: order/templates/order/sales_order_base.html:70
 msgid "Order Reference"
 msgstr ""
 
-#: order/templates/order/order_base.html:74
-#: order/templates/order/sales_order_base.html:73
+#: order/templates/order/order_base.html:76
+#: order/templates/order/sales_order_base.html:75
 msgid "Order Status"
 msgstr ""
 
-#: order/templates/order/order_base.html:85 templates/js/order.html:153
+#: order/templates/order/order_base.html:87 templates/js/order.html:153
 msgid "Supplier Reference"
 msgstr ""
 
-#: order/templates/order/order_base.html:104
+#: order/templates/order/order_base.html:106
 msgid "Issued"
 msgstr ""
 
-#: order/templates/order/order_base.html:111
+#: order/templates/order/order_base.html:113
 #: order/templates/order/purchase_order_detail.html:182
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:110
+#: order/templates/order/sales_order_base.html:112
 msgid "Received"
 msgstr ""
 
@@ -1605,8 +1621,8 @@ msgid "Attachments"
 msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:16
-#: order/templates/order/sales_order_detail.html:17 order/views.py:1087
-#: order/views.py:1201
+#: order/templates/order/sales_order_detail.html:17 order/views.py:1117
+#: order/views.py:1232
 msgid "Add Line Item"
 msgstr ""
 
@@ -1674,15 +1690,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:47
+#: order/templates/order/sales_order_base.html:57
 msgid "Packing List"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:63
+#: order/templates/order/sales_order_base.html:65
 msgid "Sales Order Details"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:84 templates/js/order.html:228
+#: order/templates/order/sales_order_base.html:86 templates/js/order.html:228
 msgid "Customer Reference"
 msgstr ""
 
@@ -1746,147 +1762,147 @@ msgstr ""
 msgid "Order Items"
 msgstr ""
 
-#: order/views.py:93
+#: order/views.py:99
 msgid "Add Purchase Order Attachment"
 msgstr ""
 
-#: order/views.py:102 order/views.py:149 part/views.py:90 stock/views.py:167
+#: order/views.py:109 order/views.py:157 part/views.py:92 stock/views.py:167
 msgid "Added attachment"
 msgstr ""
 
-#: order/views.py:141
+#: order/views.py:148
 msgid "Add Sales Order Attachment"
 msgstr ""
 
-#: order/views.py:176 order/views.py:197
+#: order/views.py:184 order/views.py:206
 msgid "Edit Attachment"
 msgstr ""
 
-#: order/views.py:180 order/views.py:201
+#: order/views.py:189 order/views.py:211
 msgid "Attachment updated"
 msgstr ""
 
-#: order/views.py:216 order/views.py:230
+#: order/views.py:226 order/views.py:241
 msgid "Delete Attachment"
 msgstr ""
 
-#: order/views.py:222 order/views.py:236 stock/views.py:223
+#: order/views.py:233 order/views.py:248 stock/views.py:223
 msgid "Deleted attachment"
 msgstr ""
 
-#: order/views.py:287
+#: order/views.py:301
 msgid "Create Purchase Order"
 msgstr ""
 
-#: order/views.py:318
+#: order/views.py:333
 msgid "Create Sales Order"
 msgstr ""
 
-#: order/views.py:348
+#: order/views.py:364
 msgid "Edit Purchase Order"
 msgstr ""
 
-#: order/views.py:368
+#: order/views.py:385
 msgid "Edit Sales Order"
 msgstr ""
 
-#: order/views.py:384
+#: order/views.py:402
 msgid "Cancel Order"
 msgstr ""
 
-#: order/views.py:399 order/views.py:431
+#: order/views.py:418 order/views.py:451
 msgid "Confirm order cancellation"
 msgstr ""
 
-#: order/views.py:417
+#: order/views.py:436
 msgid "Cancel sales order"
 msgstr ""
 
-#: order/views.py:437
+#: order/views.py:457
 msgid "Could not cancel order"
 msgstr ""
 
-#: order/views.py:451
+#: order/views.py:471
 msgid "Issue Order"
 msgstr ""
 
-#: order/views.py:466
+#: order/views.py:487
 msgid "Confirm order placement"
 msgstr ""
 
-#: order/views.py:487
+#: order/views.py:508
 msgid "Complete Order"
 msgstr ""
 
-#: order/views.py:522
+#: order/views.py:544
 msgid "Ship Order"
 msgstr ""
 
-#: order/views.py:538
+#: order/views.py:561
 msgid "Confirm order shipment"
 msgstr ""
 
-#: order/views.py:544
+#: order/views.py:567
 msgid "Could not ship order"
 msgstr ""
 
-#: order/views.py:595
+#: order/views.py:619
 msgid "Receive Parts"
 msgstr ""
 
-#: order/views.py:662
+#: order/views.py:687
 msgid "Items received"
 msgstr ""
 
-#: order/views.py:676
+#: order/views.py:701
 msgid "No destination set"
 msgstr ""
 
-#: order/views.py:721
+#: order/views.py:746
 msgid "Error converting quantity to number"
 msgstr ""
 
-#: order/views.py:727
+#: order/views.py:752
 msgid "Receive quantity less than zero"
 msgstr ""
 
-#: order/views.py:733
+#: order/views.py:758
 msgid "No lines specified"
 msgstr ""
 
-#: order/views.py:1107
+#: order/views.py:1138
 msgid "Invalid Purchase Order"
 msgstr ""
 
-#: order/views.py:1115
+#: order/views.py:1146
 msgid "Supplier must match for Part and Order"
 msgstr ""
 
-#: order/views.py:1120
+#: order/views.py:1151
 msgid "Invalid SupplierPart selection"
 msgstr ""
 
-#: order/views.py:1252 order/views.py:1270
+#: order/views.py:1284 order/views.py:1303
 msgid "Edit Line Item"
 msgstr ""
 
-#: order/views.py:1286 order/views.py:1298
+#: order/views.py:1320 order/views.py:1333
 msgid "Delete Line Item"
 msgstr ""
 
-#: order/views.py:1291 order/views.py:1303
+#: order/views.py:1326 order/views.py:1339
 msgid "Deleted line item"
 msgstr ""
 
-#: order/views.py:1312
+#: order/views.py:1348
 msgid "Allocate Stock to Order"
 msgstr ""
 
-#: order/views.py:1381
+#: order/views.py:1418
 msgid "Edit Allocation Quantity"
 msgstr ""
 
-#: order/views.py:1396
+#: order/views.py:1434
 msgid "Remove allocation"
 msgstr ""
 
@@ -2226,7 +2242,7 @@ msgstr ""
 msgid "BOM line checksum"
 msgstr ""
 
-#: part/models.py:1612 part/views.py:1349 part/views.py:1401
+#: part/models.py:1612 part/views.py:1351 part/views.py:1403
 #: stock/models.py:231
 msgid "Quantity must be integer value for trackable parts"
 msgstr ""
@@ -2293,7 +2309,7 @@ msgstr ""
 msgid "Validate Bill of Materials"
 msgstr ""
 
-#: part/templates/part/bom.html:48 part/views.py:1640
+#: part/templates/part/bom.html:48 part/views.py:1642
 msgid "Export Bill of Materials"
 msgstr ""
 
@@ -2385,7 +2401,7 @@ msgstr ""
 msgid "All parts"
 msgstr ""
 
-#: part/templates/part/category.html:24 part/views.py:2043
+#: part/templates/part/category.html:24 part/views.py:2045
 msgid "Create new part category"
 msgstr ""
 
@@ -2425,7 +2441,7 @@ msgstr ""
 msgid "Export Part Data"
 msgstr ""
 
-#: part/templates/part/category.html:114 part/views.py:511
+#: part/templates/part/category.html:114 part/views.py:513
 msgid "Create new part"
 msgstr ""
 
@@ -2642,7 +2658,7 @@ msgid "Edit"
 msgstr ""
 
 #: part/templates/part/params.html:39 part/templates/part/supplier.html:17
-#: users/models.py:141
+#: users/models.py:145
 msgid "Delete"
 msgstr ""
 
@@ -2842,176 +2858,176 @@ msgstr ""
 msgid "New Variant"
 msgstr ""
 
-#: part/views.py:78
+#: part/views.py:80
 msgid "Add part attachment"
 msgstr ""
 
-#: part/views.py:129 templates/attachment_table.html:30
+#: part/views.py:131 templates/attachment_table.html:30
 msgid "Edit attachment"
 msgstr ""
 
-#: part/views.py:135
+#: part/views.py:137
 msgid "Part attachment updated"
 msgstr ""
 
-#: part/views.py:150
+#: part/views.py:152
 msgid "Delete Part Attachment"
 msgstr ""
 
-#: part/views.py:158
+#: part/views.py:160
 msgid "Deleted part attachment"
 msgstr ""
 
-#: part/views.py:167
+#: part/views.py:169
 msgid "Create Test Template"
 msgstr ""
 
-#: part/views.py:196
+#: part/views.py:198
 msgid "Edit Test Template"
 msgstr ""
 
-#: part/views.py:212
+#: part/views.py:214
 msgid "Delete Test Template"
 msgstr ""
 
-#: part/views.py:221
+#: part/views.py:223
 msgid "Set Part Category"
 msgstr ""
 
-#: part/views.py:271
+#: part/views.py:273
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr ""
 
-#: part/views.py:306
+#: part/views.py:308
 msgid "Create Variant"
 msgstr ""
 
-#: part/views.py:386
+#: part/views.py:388
 msgid "Duplicate Part"
 msgstr ""
 
-#: part/views.py:393
+#: part/views.py:395
 msgid "Copied part"
 msgstr ""
 
-#: part/views.py:518
+#: part/views.py:520
 msgid "Created new part"
 msgstr ""
 
-#: part/views.py:733
+#: part/views.py:735
 msgid "Part QR Code"
 msgstr ""
 
-#: part/views.py:752
+#: part/views.py:754
 msgid "Upload Part Image"
 msgstr ""
 
-#: part/views.py:760 part/views.py:797
+#: part/views.py:762 part/views.py:799
 msgid "Updated part image"
 msgstr ""
 
-#: part/views.py:769
+#: part/views.py:771
 msgid "Select Part Image"
 msgstr ""
 
-#: part/views.py:800
+#: part/views.py:802
 msgid "Part image not found"
 msgstr ""
 
-#: part/views.py:811
+#: part/views.py:813
 msgid "Edit Part Properties"
 msgstr ""
 
-#: part/views.py:835
+#: part/views.py:837
 msgid "Validate BOM"
 msgstr ""
 
-#: part/views.py:1002
+#: part/views.py:1004
 msgid "No BOM file provided"
 msgstr ""
 
-#: part/views.py:1352
+#: part/views.py:1354
 msgid "Enter a valid quantity"
 msgstr ""
 
-#: part/views.py:1377 part/views.py:1380
+#: part/views.py:1379 part/views.py:1382
 msgid "Select valid part"
 msgstr ""
 
-#: part/views.py:1386
+#: part/views.py:1388
 msgid "Duplicate part selected"
 msgstr ""
 
-#: part/views.py:1424
+#: part/views.py:1426
 msgid "Select a part"
 msgstr ""
 
-#: part/views.py:1430
+#: part/views.py:1432
 msgid "Selected part creates a circular BOM"
 msgstr ""
 
-#: part/views.py:1434
+#: part/views.py:1436
 msgid "Specify quantity"
 msgstr ""
 
-#: part/views.py:1690
+#: part/views.py:1692
 msgid "Confirm Part Deletion"
 msgstr ""
 
-#: part/views.py:1699
+#: part/views.py:1701
 msgid "Part was deleted"
 msgstr ""
 
-#: part/views.py:1708
+#: part/views.py:1710
 msgid "Part Pricing"
 msgstr ""
 
-#: part/views.py:1834
+#: part/views.py:1836
 msgid "Create Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1844
+#: part/views.py:1846
 msgid "Edit Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1853
+#: part/views.py:1855
 msgid "Delete Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1863
+#: part/views.py:1865
 msgid "Create Part Parameter"
 msgstr ""
 
-#: part/views.py:1915
+#: part/views.py:1917
 msgid "Edit Part Parameter"
 msgstr ""
 
-#: part/views.py:1931
+#: part/views.py:1933
 msgid "Delete Part Parameter"
 msgstr ""
 
-#: part/views.py:1990
+#: part/views.py:1992
 msgid "Edit Part Category"
 msgstr ""
 
-#: part/views.py:2027
+#: part/views.py:2029
 msgid "Delete Part Category"
 msgstr ""
 
-#: part/views.py:2035
+#: part/views.py:2037
 msgid "Part category was deleted"
 msgstr ""
 
-#: part/views.py:2098
+#: part/views.py:2100
 msgid "Create BOM item"
 msgstr ""
 
-#: part/views.py:2166
+#: part/views.py:2168
 msgid "Edit BOM item"
 msgstr ""
 
-#: part/views.py:2216
+#: part/views.py:2218
 msgid "Confim BOM item deletion"
 msgstr ""
 
@@ -4497,58 +4513,58 @@ msgstr ""
 msgid "Delete Stock"
 msgstr ""
 
-#: users/admin.py:62
+#: users/admin.py:61
 msgid "Users"
 msgstr ""
 
-#: users/admin.py:63
+#: users/admin.py:62
 msgid "Select which users are assigned to this group"
 msgstr ""
 
-#: users/admin.py:124
+#: users/admin.py:120
 msgid "Personal info"
 msgstr ""
 
-#: users/admin.py:125
+#: users/admin.py:121
 msgid "Permissions"
 msgstr ""
 
-#: users/admin.py:128
+#: users/admin.py:124
 msgid "Important dates"
 msgstr ""
 
-#: users/models.py:124
+#: users/models.py:128
 msgid "Permission set"
 msgstr ""
 
-#: users/models.py:132
+#: users/models.py:136
 msgid "Group"
 msgstr ""
 
-#: users/models.py:135
+#: users/models.py:139
 msgid "View"
 msgstr ""
 
-#: users/models.py:135
+#: users/models.py:139
 msgid "Permission to view items"
 msgstr ""
 
-#: users/models.py:137
-msgid "Create"
-msgstr ""
-
-#: users/models.py:137
-msgid "Permission to add items"
-msgstr ""
-
-#: users/models.py:139
-msgid "Update"
-msgstr ""
-
-#: users/models.py:139
-msgid "Permissions to edit items"
+#: users/models.py:141
+msgid "Add"
 msgstr ""
 
 #: users/models.py:141
+msgid "Permission to add items"
+msgstr ""
+
+#: users/models.py:143
+msgid "Change"
+msgstr ""
+
+#: users/models.py:143
+msgid "Permissions to edit items"
+msgstr ""
+
+#: users/models.py:145
 msgid "Permission to delete items"
 msgstr ""