From 75a1be0284244e79ba08d986c2957d9e852d4955 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 4 Aug 2021 17:25:51 +1000
Subject: [PATCH 01/16] Use API forms for creating and editing BomItem objects

---
 InvenTree/part/forms.py                   |  28 -----
 InvenTree/part/templates/part/detail.html |  30 ++---
 InvenTree/part/urls.py                    |  10 --
 InvenTree/part/views.py                   | 128 ----------------------
 InvenTree/templates/js/translated/bom.js  |  35 ++++--
 5 files changed, 43 insertions(+), 188 deletions(-)

diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 9523550198..1fc2848440 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -18,7 +18,6 @@ import common.models
 from common.forms import MatchItemForm
 
 from .models import Part, PartCategory, PartRelated
-from .models import BomItem
 from .models import PartParameterTemplate, PartParameter
 from .models import PartCategoryParameterTemplate
 from .models import PartSellPriceBreak, PartInternalPriceBreak
@@ -317,33 +316,6 @@ class EditCategoryParameterTemplateForm(HelperForm):
         ]
 
 
-class EditBomItemForm(HelperForm):
-    """ Form for editing a BomItem object """
-
-    quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
-
-    sub_part = PartModelChoiceField(queryset=Part.objects.all(), label=_('Sub part'))
-
-    class Meta:
-        model = BomItem
-        fields = [
-            'part',
-            'sub_part',
-            'quantity',
-            'reference',
-            'overage',
-            'note',
-            'allow_variants',
-            'inherited',
-            'optional',
-        ]
-
-        # Prevent editing of the part associated with this BomItem
-        widgets = {
-            'part': forms.HiddenInput()
-        }
-
-
 class PartPriceForm(forms.Form):
     """ Simple form for viewing part pricing information """
 
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 59aec17944..267b880d49 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -440,22 +440,22 @@
     });
 
     $("#bom-item-new").click(function () {
-        launchModalForm(
-            "{% url 'bom-item-create' %}?parent={{ part.id }}",
-            {
-                success: function() {
-                    $("#bom-table").bootstrapTable('refresh');
-                },
-                secondary: [
-                    {
-                        field: 'sub_part',
-                        label: '{% trans "New Part" %}',
-                        title: '{% trans "Create New Part" %}',
-                        url: "{% url 'part-create' %}",
-                    },
-                ]
+
+        var fields = bomItemFields();
+
+        fields.part.value = {{ part.pk }};
+        fields.sub_part.filters = {
+            active: true,
+        };
+
+        constructForm('{% url "api-bom-list" %}', {
+            fields: fields,
+            method: 'POST',
+            title: '{% trans "Create BOM Item" %}',
+            onSuccess: function() {
+                $('#bom-table').bootstrapTable('refresh');
             }
-        );
+        });
     });
 
     {% else %}
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 62061f8279..52e9b929c1 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -78,10 +78,6 @@ category_urls = [
     ]))
 ]
 
-part_bom_urls = [
-    url(r'^edit/?', views.BomItemEdit.as_view(), name='bom-item-edit'),
-]
-
 # URL list for part web interface
 part_urls = [
 
@@ -92,9 +88,6 @@ part_urls = [
     url(r'^import/', views.PartImport.as_view(), name='part-import'),
     url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
 
-    # Create a new BOM item
-    url(r'^bom/new/?', views.BomItemCreate.as_view(), name='bom-item-create'),
-
     # Download a BOM upload template
     url(r'^bom_template/?', views.BomUploadTemplate.as_view(), name='bom-upload-template'),
 
@@ -122,9 +115,6 @@ part_urls = [
     # Change category for multiple parts
     url(r'^set-category/?', views.PartSetCategory.as_view(), name='part-set-category'),
 
-    # Bom Items
-    url(r'^bom/(?P<pk>\d+)/', include(part_bom_urls)),
-
     # Individual part using IPN as slug
     url(r'^(?P<slug>[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'),
 
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index dd2868b72b..b35e752351 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -2078,134 +2078,6 @@ class CategoryParameterTemplateDelete(AjaxDeleteView):
         return self.object
 
 
-class BomItemCreate(AjaxCreateView):
-    """
-    Create view for making a new BomItem object
-    """
-
-    model = BomItem
-    form_class = part_forms.EditBomItemForm
-    ajax_template_name = 'modal_form.html'
-    ajax_form_title = _('Create BOM Item')
-
-    def get_form(self):
-        """ Override get_form() method to reduce Part selection options.
-
-        - Do not allow part to be added to its own BOM
-        - Remove any Part items that are already in the BOM
-        """
-
-        form = super(AjaxCreateView, self).get_form()
-
-        part_id = form['part'].value()
-
-        # Construct a queryset for the part field
-        part_query = Part.objects.filter(active=True)
-
-        # Construct a queryset for the sub_part field
-        sub_part_query = Part.objects.filter(
-            component=True,
-            active=True
-        )
-
-        try:
-            part = Part.objects.get(id=part_id)
-
-            # Hide the 'part' field
-            form.fields['part'].widget = HiddenInput()
-
-            # Exclude the part from its own BOM
-            sub_part_query = sub_part_query.exclude(id=part.id)
-
-            # Eliminate any options that are already in the BOM!
-            sub_part_query = sub_part_query.exclude(id__in=[item.id for item in part.getRequiredParts()])
-
-        except (ValueError, Part.DoesNotExist):
-            pass
-
-        # Set the querysets for the fields
-        form.fields['part'].queryset = part_query
-        form.fields['sub_part'].queryset = sub_part_query
-
-        return form
-
-    def get_initial(self):
-        """ Provide initial data for the BomItem:
-
-        - If 'parent' provided, set the parent part field
-        """
-
-        # Look for initial values
-        initials = super(BomItemCreate, self).get_initial().copy()
-
-        # Parent part for this item?
-        parent_id = self.request.GET.get('parent', None)
-
-        if parent_id:
-            try:
-                initials['part'] = Part.objects.get(pk=parent_id)
-            except Part.DoesNotExist:
-                pass
-
-        return initials
-
-
-class BomItemEdit(AjaxUpdateView):
-    """ Update view for editing BomItem """
-
-    model = BomItem
-    form_class = part_forms.EditBomItemForm
-    ajax_template_name = 'modal_form.html'
-    ajax_form_title = _('Edit BOM item')
-
-    def get_form(self):
-        """ Override get_form() method to filter part selection options
-
-        - Do not allow part to be added to its own BOM
-        - Remove any part items that are already in the BOM
-        """
-
-        item = self.get_object()
-
-        form = super().get_form()
-
-        part_id = form['part'].value()
-
-        try:
-            part = Part.objects.get(pk=part_id)
-
-            # Construct a queryset
-            query = Part.objects.filter(component=True)
-
-            # Limit to "active" items, *unless* the currently selected item is not active
-            if item.sub_part.active:
-                query = query.filter(active=True)
-
-            # Prevent the parent part from being selected
-            query = query.exclude(pk=part_id)
-
-            # Eliminate any options that are already in the BOM,
-            # *except* for the item which is already selected
-            try:
-                sub_part_id = int(form['sub_part'].value())
-            except ValueError:
-                sub_part_id = -1
-
-            existing = [item.pk for item in part.getRequiredParts()]
-
-            if sub_part_id in existing:
-                existing.remove(sub_part_id)
-
-            query = query.exclude(id__in=existing)
-
-            form.fields['sub_part'].queryset = query
-
-        except (ValueError, Part.DoesNotExist):
-            pass
-
-        return form
-
-
 class PartSalePriceBreakCreate(AjaxCreateView):
     """
     View for creating a sale price break for a part
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index 20829bad79..34a6206ac9 100644
--- a/InvenTree/templates/js/translated/bom.js
+++ b/InvenTree/templates/js/translated/bom.js
@@ -8,6 +8,26 @@
  */
 
 
+function bomItemFields() {
+
+    return {
+        part: {
+            hidden: true,
+        },
+        sub_part: {
+        },
+        quantity: {},
+        reference: {},
+        overage: {},
+        note: {},
+        allow_variants: {},
+        inherited: {},
+        optional: {},
+    };
+
+}
+
+
 function reloadBomTable(table, options) {
 
     table.bootstrapTable('refresh');
@@ -528,14 +548,15 @@ function loadBomTable(table, options) {
             var pk = $(this).attr('pk');
             var url = `/part/bom/${pk}/edit/`;
 
-            launchModalForm(
-                url,
-                {
-                    success: function() {
-                        reloadBomTable(table);
-                    }
+            var fields = bomItemFields();
+
+            constructForm(`/api/bom/${pk}/`, {
+                fields: fields,
+                title: '{% trans "Edit BOM Item" %}',
+                onSuccess: function() {
+                    reloadBomTable(table);
                 }
-            );
+            });
         });
 
         table.on('click', '.bom-validate-button', function() {

From 2e8a490ca9cbb18b67309da5c15b8d42103dbbb0 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 4 Aug 2021 17:41:47 +1000
Subject: [PATCH 02/16] Fixes for unit tests

---
 InvenTree/part/test_views.py | 19 -------------------
 1 file changed, 19 deletions(-)

diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index 139ec20479..206d4dd56a 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -259,22 +259,3 @@ class CategoryTest(PartViewTestCase):
 
         response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
         self.assertEqual(response.status_code, 200)
-
-
-class BomItemTests(PartViewTestCase):
-    """ Tests for BomItem related views """
-
-    def test_create_valid_parent(self):
-        """ Create a BomItem for a valid part """
-        response = self.client.get(reverse('bom-item-create'), {'parent': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
-    def test_create_no_parent(self):
-        """ Create a BomItem without a parent """
-        response = self.client.get(reverse('bom-item-create'), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
-    def test_create_invalid_parent(self):
-        """ Create a BomItem with an invalid parent """
-        response = self.client.get(reverse('bom-item-create'), {'parent': 99999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)

From a64ee23afc2aaf01ab5578dd571248ee5b6538b2 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 4 Aug 2021 23:16:11 +1000
Subject: [PATCH 03/16] Add more options for form rendering

- "before" a field
- "after" a field
- pure "eye candy" field
---
 InvenTree/templates/js/translated/forms.js | 45 ++++++++++++++++++++--
 1 file changed, 42 insertions(+), 3 deletions(-)

diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index 4801ec77eb..46b2b21a87 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -366,6 +366,10 @@ function constructFormBody(fields, options) {
 
             // TODO: Refactor the following code with Object.assign (see above)
 
+            // "before" and "after" renders
+            fields[field].before = field_options.before;
+            fields[field].after = field_options.after;
+
             // Secondary modal options
             fields[field].secondary = field_options.secondary;
 
@@ -560,10 +564,15 @@ function submitFormData(fields, options) {
     var has_files = false;
 
     // Extract values for each field
-    options.field_names.forEach(function(name) {
+    for (var idx = 0; idx < options.fields_names.length; idx++) {
+
+        var name = options.field_names[idx];
 
         var field = fields[name] || null;
 
+        // Ignore visual fields
+        if (field && field.type == 'candy') continue;
+
         if (field) {
 
             var value = getFormFieldValue(name, field, options);
@@ -593,7 +602,7 @@ function submitFormData(fields, options) {
         } else {
             console.log(`WARNING: Could not find field matching '${name}'`);
         }
-    });
+    }
 
     var upload_func = inventreePut;
 
@@ -1279,6 +1288,11 @@ function renderModelData(name, model, data, parameters, options) {
  */
 function constructField(name, parameters, options) {
 
+    // Shortcut for simple visual fields
+    if (parameters.type == 'candy') {
+        return constructCandyInput(name, parameters, options);
+    }
+
     var field_name = `id_${name}`;
 
     // Hidden inputs are rendered without label / help text / etc
@@ -1292,7 +1306,14 @@ function constructField(name, parameters, options) {
         form_classes += ' has-error';
     }
 
-    var html = `<div id='div_${field_name}' class='${form_classes}'>`;
+    var html = '';
+    
+    // Optional content to render before the field
+    if (parameters.before) {
+        html += parameters.before;
+    }
+    
+    html += `<div id='div_${field_name}' class='${form_classes}'>`;
 
     // Add a label
     html += constructLabel(name, parameters);
@@ -1352,6 +1373,10 @@ function constructField(name, parameters, options) {
     html += `</div>`;   // controls
     html += `</div>`;   // form-group
     
+    if (parameters.after) {
+        html += parameters.after;
+    }
+
     return html;
 }
 
@@ -1430,6 +1455,9 @@ function constructInput(name, parameters, options) {
         case 'date':
             func = constructDateInput;
             break;
+        case 'candy':
+            func = constructCandyInput;
+            break;
         default:
             // Unsupported field type!
             break;
@@ -1658,6 +1686,17 @@ function constructDateInput(name, parameters, options) {
 }
 
 
+/*
+ * Construct a "candy" field input
+ * No actual field data!
+ */
+function constructCandyInput(name, parameters, options) {
+
+    return parameters.html;
+
+}
+
+
 /*
  * Construct a 'help text' div based on the field parameters
  * 

From 2bf3e3ab020a9030dd73d51c11ac426437d53a60 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 4 Aug 2021 23:26:17 +1000
Subject: [PATCH 04/16] Function to construct part form fields

---
 InvenTree/part/api.py                      |   2 +
 InvenTree/templates/js/translated/forms.js |   2 +-
 InvenTree/templates/js/translated/part.js  | 110 +++++++++++++++++++++
 3 files changed, 113 insertions(+), 1 deletion(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index a01b05034f..3b91d27c81 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -23,6 +23,7 @@ from djmoney.money import Money
 from djmoney.contrib.exchange.models import convert_money
 from djmoney.contrib.exchange.exceptions import MissingRate
 
+from decimal import Decimal
 
 from .models import Part, PartCategory, BomItem
 from .models import PartParameter, PartParameterTemplate
@@ -30,6 +31,7 @@ from .models import PartAttachment, PartTestTemplate
 from .models import PartSellPriceBreak, PartInternalPriceBreak
 from .models import PartCategoryParameterTemplate
 
+from stock.models import StockItem
 from common.models import InvenTreeSetting
 from build.models import Build
 
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index 46b2b21a87..27337d97e7 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -564,7 +564,7 @@ function submitFormData(fields, options) {
     var has_files = false;
 
     // Extract values for each field
-    for (var idx = 0; idx < options.fields_names.length; idx++) {
+    for (var idx = 0; idx < options.field_names.length; idx++) {
 
         var name = options.field_names[idx];
 
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index aaee9e47a0..fafdaa94e7 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -13,6 +13,116 @@ function yesNoLabel(value) {
     }
 }
 
+// Construct fieldset for part forms
+function partFields(options={}) {
+
+    var fields = {
+        category: {},
+        name: {},
+        IPN: {},
+        revision: {},
+        description: {},
+        variant_of: {},
+        keywords: {
+            icon: 'fa-key',
+        },
+        units: {},
+        link: {
+            icon: 'fa-link',
+        },
+        default_location: {},
+        default_supplier: {},
+        default_expiry: {
+            icon: 'fa-calendar-alt',
+        },
+        minimum_stock: {
+            icon: 'fa-boxes',
+        },
+        attributes: {
+            type: 'candy',
+            html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
+        },
+        component: {
+            value: global_settings.PART_COMPONENT,
+        },
+        assembly: {
+            value: global_settings.PART_ASSEMBLY,
+        },
+        is_template: {
+            value: global_settings.PART_TEMPLATE,
+        },
+        trackable: {
+            value: global_settings.PART_TRACKABLE,
+        },
+        purchaseable: {
+            value: global_settings.PART_PURCHASEABLE,
+        },
+        salable: {
+            value: global_settings.PART_SALABLE,
+        },
+        virtual: {
+            value: global_settings.PART_VIRTUAL,
+        },
+    };
+
+    // Pop expiry field
+    if (!global_settings.STOCK_ENABLE_EXPIRY) {
+        delete fields["default_expiry"];
+    }
+
+    // Additional fields when "creating" a new part
+    if (options.create) {
+
+        // No supplier parts available yet
+        delete fields["default_supplier"];
+
+        fields.create = {
+            type: 'candy',
+            html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`,
+        };
+
+        if (global_settings.PART_CREATE_INITIAL) {
+            fields.initial_stock = {
+                type: 'decimal',
+                label: '{% trans "Initial Stock Quantity" %}',
+                help_text: '{% trans "Initialize part stock with specified quantity" %}',
+            };
+        }
+
+        fields.copy_category_parameters = {
+            type: 'boolean',
+            label: '{% trans "Copy Category Parameters" %}',
+            help_text: '{% trans "Copy parameter templates from selected part category" %}',
+            value: global_settings.PART_CATEGORY_PARAMETERS,
+        };
+    }
+
+    // Additional fields when "duplicating" a part
+    if (options.duplicate) {
+
+        fields.duplicate = {
+            type: 'candy',
+            html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
+        };
+
+        fields.copy_bom = {
+            type: 'boolean',
+            label: '{% trans "Copy BOM" %}',
+            help_text: '{% trans "Copy bill of materials from original part" %}',
+            value: global_settings.PART_COPY_BOM,
+        };
+
+        fields.copy_parameters = {
+            type: 'boolean',
+            label: '{% trans "Copy Parameters" %}',
+            help_text: '{% trans "Copy parameter data from original part" %}',
+            value: global_settings.PART_COPY_PARAMETERS,
+        };
+    }
+
+    return fields;
+}
+
 
 function categoryFields() {
     return {

From b04f22fc53848981c1a581f73ae21312eb9a43a6 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 4 Aug 2021 23:27:16 +1000
Subject: [PATCH 05/16] CreatePart form now uses the API

- Simplify the way category parameter templates are copied
---
 InvenTree/common/models.py                  |   3 +-
 InvenTree/part/api.py                       |  35 ++++-
 InvenTree/part/models.py                    |  48 +++---
 InvenTree/part/templates/part/category.html |  38 ++---
 InvenTree/part/test_views.py                |  13 --
 InvenTree/part/urls.py                      |   3 -
 InvenTree/part/views.py                     | 161 +-------------------
 7 files changed, 74 insertions(+), 227 deletions(-)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 5d75a4dd74..839780d5b4 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -637,7 +637,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
         'PART_PURCHASEABLE': {
             'name': _('Purchaseable'),
             'description': _('Parts are purchaseable by default'),
-            'default': False,
+            'default': True,
             'validator': bool,
         },
 
@@ -662,6 +662,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'validator': bool,
         },
 
+        # TODO: Remove this setting in future, new API forms make this not useful
         'PART_SHOW_QUANTITY_IN_FORMS': {
             'name': _('Show Quantity in Forms'),
             'description': _('Display available part quantity in some forms'),
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 3b91d27c81..88866ad58c 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -630,16 +630,47 @@ class PartList(generics.ListCreateAPIView):
         else:
             return Response(data)
 
-    def perform_create(self, serializer):
+    def create(self, request, *args, **kwargs):
         """
         We wish to save the user who created this part!
 
         Note: Implementation copied from DRF class CreateModelMixin
         """
 
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+
         part = serializer.save()
         part.creation_user = self.request.user
-        part.save()
+
+        # Optionally copy templates from category or parent category
+        copy_templates = {
+            'main': str2bool(request.data.get('copy_category_templates', False)),
+            'parent': str2bool(request.data.get('copy_parent_templates', False))
+        }
+
+        part.save(**{'add_category_templates': copy_templates})
+
+        # Optionally create initial stock item
+        try:
+            initial_stock = Decimal(request.data.get('initial_stock', 0))
+
+            if initial_stock > 0 and part.default_location is not None:
+
+                stock_item = StockItem(
+                    part=part,
+                    quantity=initial_stock,
+                    location=part.default_location,
+                )
+
+                stock_item.save(user=request.user)
+
+        except:
+            pass
+
+        headers = self.get_success_headers(serializer.data)
+
+        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
 
     def get_queryset(self, *args, **kwargs):
 
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 2dd5d3ad7f..b75edde9cc 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -409,7 +409,7 @@ class Part(MPTTModel):
         """
 
         # Get category templates settings
-        add_category_templates = kwargs.pop('add_category_templates', None)
+        add_category_templates = kwargs.pop('add_category_templates', False)
 
         if self.pk:
             previous = Part.objects.get(pk=self.pk)
@@ -437,39 +437,29 @@ class Part(MPTTModel):
             # Get part category
             category = self.category
 
-            if category and add_category_templates:
-                # Store templates added to part
+            if category is not None:
+
                 template_list = []
 
-                # Create part parameters for selected category
-                category_templates = add_category_templates['main']
-                if category_templates:
+                parent_categories = category.get_ancestors(include_self=True)
+
+                for category in parent_categories:
                     for template in category.get_parameter_templates():
-                        parameter = PartParameter.create(part=self,
-                                                         template=template.parameter_template,
-                                                         data=template.default_value,
-                                                         save=True)
-                        if parameter:
+                        # Check that template wasn't already added
+                        if template.parameter_template not in template_list:
+
                             template_list.append(template.parameter_template)
 
-                # Create part parameters for parent category
-                category_templates = add_category_templates['parent']
-                if category_templates:
-                    # Get parent categories
-                    parent_categories = category.get_ancestors()
-
-                    for category in parent_categories:
-                        for template in category.get_parameter_templates():
-                            # Check that template wasn't already added
-                            if template.parameter_template not in template_list:
-                                try:
-                                    PartParameter.create(part=self,
-                                                         template=template.parameter_template,
-                                                         data=template.default_value,
-                                                         save=True)
-                                except IntegrityError:
-                                    # PartParameter already exists
-                                    pass
+                            try:
+                                PartParameter.create(
+                                    part=self,
+                                    template=template.parameter_template,
+                                    data=template.default_value,
+                                    save=True
+                                )
+                            except IntegrityError:
+                                # PartParameter already exists
+                                pass
 
     def __str__(self):
         return f"{self.full_name} - {self.description}"
diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html
index 1c41092574..b149fd28ed 100644
--- a/InvenTree/part/templates/part/category.html
+++ b/InvenTree/part/templates/part/category.html
@@ -264,25 +264,25 @@
 
     {% if roles.part.add %}
     $("#part-create").click(function() {
-        launchModalForm(
-            "{% url 'part-create' %}",
-            {
-                follow: true,
-                data: {
-                {% if category %}
-                    category: {{ category.id }}
-                {% endif %}
-                },
-                secondary: [
-                    {
-                        field: 'default_location',
-                        label: '{% trans "New Location" %}',
-                        title: '{% trans "Create new Stock Location" %}',
-                        url: "{% url 'stock-location-create' %}",
-                    }
-                ]   
-            }
-        );
+
+        var fields = partFields({
+            create: true,
+        });
+
+        {% if category %}
+        fields.category.value = {{ category.pk }};
+        {% endif %}
+
+        constructForm('{% url "api-part-list" %}', {
+            method: 'POST',
+            fields: fields,
+            title: '{% trans "Create Part" %}',
+            onSuccess: function(data) {
+                // Follow the new part
+                location.href = `/part/${data.pk}/`;
+            },
+        });
+        
     });
     {% endif %}
 
diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index 206d4dd56a..c555687183 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -158,19 +158,6 @@ class PartDetailTest(PartViewTestCase):
 class PartTests(PartViewTestCase):
     """ Tests for Part forms """
 
-    def test_part_create(self):
-        """ Launch form to create a new part """
-        response = self.client.get(reverse('part-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
-        # And again, with an invalid category
-        response = self.client.get(reverse('part-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
-        # And again, with no category
-        response = self.client.get(reverse('part-create'), {'name': 'Test part'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
     def test_part_duplicate(self):
         """ Launch form to duplicate part """
 
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 52e9b929c1..0802a94f1a 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -81,9 +81,6 @@ category_urls = [
 # URL list for part web interface
 part_urls = [
 
-    # Create a new part
-    url(r'^new/?', views.PartCreate.as_view(), name='part-create'),
-
     # Upload a part
     url(r'^import/', views.PartImport.as_view(), name='part-import'),
     url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'),
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index b35e752351..3e4b6c59d7 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -44,7 +44,7 @@ from common.files import FileManager
 from common.views import FileManagementFormView, FileManagementAjaxView
 from common.forms import UploadFileForm, MatchFieldForm
 
-from stock.models import StockItem, StockLocation
+from stock.models import StockLocation
 
 import common.settings as inventree_settings
 
@@ -438,165 +438,6 @@ class PartDuplicate(AjaxCreateView):
         return initials
 
 
-class PartCreate(AjaxCreateView):
-    """ View for creating a new Part object.
-
-    Options for providing initial conditions:
-
-    - Provide a category object as initial data
-    """
-    model = Part
-    form_class = part_forms.EditPartForm
-
-    ajax_form_title = _('Create New Part')
-    ajax_template_name = 'part/create_part.html'
-
-    def get_data(self):
-        return {
-            'success': _("Created new part"),
-        }
-
-    def get_category_id(self):
-        return self.request.GET.get('category', None)
-
-    def get_context_data(self, **kwargs):
-        """ Provide extra context information for the form to display:
-
-        - Add category information (if provided)
-        """
-        context = super(PartCreate, self).get_context_data(**kwargs)
-
-        # Add category information to the page
-        cat_id = self.get_category_id()
-
-        if cat_id:
-            try:
-                context['category'] = PartCategory.objects.get(pk=cat_id)
-            except (PartCategory.DoesNotExist, ValueError):
-                pass
-
-        return context
-
-    def get_form(self):
-        """ Create Form for making new Part object.
-        Remove the 'default_supplier' field as there are not yet any matching SupplierPart objects
-        """
-        form = super(AjaxCreateView, self).get_form()
-
-        # Hide the "default expiry" field if the feature is not enabled
-        if not inventree_settings.stock_expiry_enabled():
-            form.fields['default_expiry'].widget = HiddenInput()
-
-        # Hide the "initial stock amount" field if the feature is not enabled
-        if not InvenTreeSetting.get_setting('PART_CREATE_INITIAL'):
-            form.fields['initial_stock'].widget = HiddenInput()
-
-        # Hide the default_supplier field (there are no matching supplier parts yet!)
-        form.fields['default_supplier'].widget = HiddenInput()
-
-        # Display category templates widgets
-        form.fields['selected_category_templates'].widget = CheckboxInput()
-        form.fields['parent_category_templates'].widget = CheckboxInput()
-
-        return form
-
-    def post(self, request, *args, **kwargs):
-
-        form = self.get_form()
-
-        context = {}
-
-        valid = form.is_valid()
-
-        name = request.POST.get('name', None)
-
-        if name:
-            matches = match_part_names(name)
-
-            if len(matches) > 0:
-
-                # Limit to the top 5 matches (to prevent clutter)
-                context['matches'] = matches[:5]
-
-                # Enforce display of the checkbox
-                form.fields['confirm_creation'].widget = CheckboxInput()
-
-                # Check if the user has checked the 'confirm_creation' input
-                confirmed = str2bool(request.POST.get('confirm_creation', False))
-
-                if not confirmed:
-                    msg = _('Possible matches exist - confirm creation of new part')
-                    form.add_error('confirm_creation', msg)
-
-                    form.pre_form_warning = msg
-                    valid = False
-
-        data = {
-            'form_valid': valid
-        }
-
-        if valid:
-            # Create the new Part
-            part = form.save(commit=False)
-
-            # Record the user who created this part
-            part.creation_user = request.user
-
-            # Store category templates settings
-            add_category_templates = {
-                'main': form.cleaned_data['selected_category_templates'],
-                'parent': form.cleaned_data['parent_category_templates'],
-            }
-
-            # Save part and pass category template settings
-            part.save(**{'add_category_templates': add_category_templates})
-
-            # Add stock if set
-            init_stock = int(request.POST.get('initial_stock', 0))
-            if init_stock:
-                stock = StockItem(part=part,
-                                  quantity=init_stock,
-                                  location=part.default_location)
-                stock.save()
-
-            data['pk'] = part.pk
-            data['text'] = str(part)
-
-            try:
-                data['url'] = part.get_absolute_url()
-            except AttributeError:
-                pass
-
-        return self.renderJsonResponse(request, form, data, context=context)
-
-    def get_initial(self):
-        """ Get initial data for the new Part object:
-
-        - If a category is provided, pre-fill the Category field
-        """
-
-        initials = super(PartCreate, self).get_initial()
-
-        if self.get_category_id():
-            try:
-                category = PartCategory.objects.get(pk=self.get_category_id())
-                initials['category'] = category
-                initials['keywords'] = category.default_keywords
-            except (PartCategory.DoesNotExist, ValueError):
-                pass
-
-        # Allow initial data to be passed through as arguments
-        for label in ['name', 'IPN', 'description', 'revision', 'keywords']:
-            if label in self.request.GET:
-                initials[label] = self.request.GET.get(label)
-
-        # Automatically create part parameters from category templates
-        initials['selected_category_templates'] = str2bool(InvenTreeSetting.get_setting('PART_CATEGORY_PARAMETERS', False))
-        initials['parent_category_templates'] = initials['selected_category_templates']
-
-        return initials
-
-
 class PartImport(FileManagementFormView):
     ''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
     permission_required = 'part.add'

From 1fafaf857720ef578392e0444b963b1c5abd1208 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 4 Aug 2021 23:29:39 +1000
Subject: [PATCH 06/16] Refactor partfields function (was essentially
 duplicated)

---
 InvenTree/templates/js/translated/part.js | 81 +++--------------------
 1 file changed, 8 insertions(+), 73 deletions(-)

diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index fafdaa94e7..988481d77c 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -65,6 +65,11 @@ function partFields(options={}) {
         },
     };
 
+    // If editing a part, we can set the "active" status
+    if (options.edit) {
+        fields.active = {};
+    }
+
     // Pop expiry field
     if (!global_settings.STOCK_ENABLE_EXPIRY) {
         delete fields["default_expiry"];
@@ -159,79 +164,9 @@ function editPart(pk, options={}) {
 
     var url = `/api/part/${pk}/`;
 
-    var fields =  {
-        category: {
-            /*
-            secondary: {
-                label: '{% trans "New Category" %}',
-                title: '{% trans "Create New Part Category" %}',
-                api_url: '{% url "api-part-category-list" %}',
-                method: 'POST',
-                fields: {
-                    name: {},
-                    description: {},
-                    parent: {
-                        secondary: {
-                            title: '{% trans "New Parent" %}',
-                            api_url: '{% url "api-part-category-list" %}',
-                            method: 'POST',
-                            fields: {
-                                name: {},
-                                description: {},
-                                parent: {},
-                            }
-                        }
-                    },
-                }
-            },
-            */
-        },
-        name: {
-            placeholder: 'part name',
-        },
-        IPN: {},
-        description: {},
-        revision: {},
-        keywords: {
-            icon: 'fa-key',
-        },
-        variant_of: {},
-        link: {
-            icon: 'fa-link',
-        },
-        default_location: {
-            /*
-            secondary: {
-                label: '{% trans "New Location" %}',
-                title: '{% trans "Create new stock location" %}',
-            },
-            */
-        },
-        default_supplier: {
-            filters: {
-                part: pk,
-                part_detail: true,
-                manufacturer_detail: true,
-                supplier_detail: true,
-            },
-            /*
-            secondary: {
-                label: '{% trans "New Supplier Part" %}',
-                title: '{% trans "Create new supplier part" %}',
-            }
-            */
-        },
-        units: {},
-        minimum_stock: {},
-        virtual: {},
-        is_template: {},
-        assembly: {},
-        component: {},
-        trackable: {},
-        purchaseable: {},
-        salable: {},
-        active: {},
-    };
+    var fields = partFields({
+        edit: true
+    });
 
     constructForm(url, {
         fields: fields,

From 408ff639ddb18e6c0d539aed7754f759dcea1bb3 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Wed, 4 Aug 2021 23:48:21 +1000
Subject: [PATCH 07/16] Adds ability to pre-fill a form with a complete dataset

---
 InvenTree/templates/js/translated/forms.js | 17 ++++++++++++++++-
 1 file changed, 16 insertions(+), 1 deletion(-)

diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index 27337d97e7..3b55802f38 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -240,6 +240,7 @@ function constructDeleteForm(fields, options) {
  *      - hidden: Set to true to hide the field
  *      - icon: font-awesome icon to display before the field
  *      - prefix: Custom HTML prefix to display before the field
+ * - data: map of data to fill out field values with
  * - focus: Name of field to focus on when modal is displayed
  * - preventClose: Set to true to prevent form from closing on success
  * - onSuccess: callback function when form action is successful
@@ -263,6 +264,11 @@ function constructForm(url, options) {
     // Default HTTP method
     options.method = options.method || 'PATCH';
 
+    // Construct an "empty" data object if not provided
+    if (!options.data) {
+        options.data = {};
+    }
+
     // Request OPTIONS endpoint from the API
     getApiEndpointOptions(url, function(OPTIONS) {
 
@@ -346,10 +352,19 @@ function constructFormBody(fields, options) {
     // otherwise *all* fields will be displayed
     var displayed_fields = options.fields || fields;
 
+    // Handle initial data overrides
+    if (options.data) {
+        for (const field in options.data) {
+
+            if (field in fields) {
+                fields[field].value = options.data[field];
+            }
+        }
+    }
+
     // Provide each field object with its own name
     for(field in fields) {
         fields[field].name = field;
-
         
         // If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
         if (fields[field].instance_filters) {

From 2cb0b448b77b2dd429167c38f4d4ec60e37a1871 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 5 Aug 2021 00:15:55 +1000
Subject: [PATCH 08/16] Fix error message styles for API errors

- django ValidationError uses "__all__" key for non_field_errors
- whyyyyyyyyyyyy
---
 InvenTree/InvenTree/serializers.py        | 16 ++++++++++++---
 InvenTree/templates/js/translated/part.js | 25 +++++++++++++++++++++++
 2 files changed, 38 insertions(+), 3 deletions(-)

diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py
index 58d33697b7..baf08e112b 100644
--- a/InvenTree/InvenTree/serializers.py
+++ b/InvenTree/InvenTree/serializers.py
@@ -85,8 +85,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
     """
 
     def __init__(self, instance=None, data=empty, **kwargs):
-
-        # self.instance = instance
+        """
+        Custom __init__ routine to ensure that *default* values (as specified in the ORM)
+        are used by the DRF serializers, *if* the values are not provided by the user.
+        """
 
         # If instance is None, we are creating a new instance
         if instance is None and data is not empty:
@@ -193,7 +195,15 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
         try:
             instance.full_clean()
         except (ValidationError, DjangoValidationError) as exc:
-            raise ValidationError(detail=serializers.as_serializer_error(exc))
+
+            data = exc.message_dict
+
+            # Change '__all__' key (django style) to 'non_field_errors' (DRF style)
+            if '__all__' in data:
+                data['non_field_errors'] = data['__all__']
+                del data['__all__']
+
+            raise ValidationError(data)
 
         return data
 
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 988481d77c..f8b410c9c0 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -173,7 +173,32 @@ function editPart(pk, options={}) {
         title: '{% trans "Edit Part" %}',
         reload: true,
     });
+}
 
+
+function duplicatePart(pk, options={}) {
+
+    // First we need all the part information
+    inventreeGet(`/api/part/${pk}/`, {}, {
+
+        success: function(response) {
+            
+            var fields = partFields({
+                duplicate: true
+            });
+            
+            constructForm('{% url "api-part-list" %}', {
+                method: 'POST',
+                fields: fields,
+                title: '{% trans "Duplicate Part" %}',
+                data: response,
+                onSuccess: function(data) {
+                    // Follow the new part
+                    location.href = `/part/${data.pk}/`;
+                }
+            });
+        }
+    });
 }
 
 

From 0e8fb6a5ad6df073770c243f9af96336f920365f Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 5 Aug 2021 00:16:42 +1000
Subject: [PATCH 09/16] Refactored DuplicatePart form

- API endpoint now takes care of duplication of other data
---
 InvenTree/part/api.py                        |  28 +++++
 InvenTree/part/templates/part/part_base.html |   7 +-
 InvenTree/part/urls.py                       |   1 -
 InvenTree/part/views.py                      | 124 -------------------
 InvenTree/templates/js/translated/part.js    |  15 ++-
 5 files changed, 43 insertions(+), 132 deletions(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 88866ad58c..789ba9b9b7 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -651,6 +651,34 @@ class PartList(generics.ListCreateAPIView):
 
         part.save(**{'add_category_templates': copy_templates})
 
+        # Optionally copy data from another part (e.g. when duplicating)
+        copy_from = request.data.get('copy_from', None)
+
+        if copy_from is not None:
+
+            try:
+                original = Part.objects.get(pk=copy_from)
+
+                copy_bom = str2bool(request.data.get('copy_bom', False))
+                copy_parameters = str2bool(request.data.get('copy_parameters', False))
+                copy_image = str2bool(request.data.get('copy_image', True))
+
+                # Copy image?
+                if copy_image:
+                    part.image = original.image
+                    part.save()
+
+                # Copy BOM?
+                if copy_bom:
+                    part.copy_bom_from(original)
+
+                # Copy parameter data?
+                if copy_parameters:
+                    part.copy_parameters_from(original)
+
+            except (ValueError, Part.DoesNotExist):
+                pass
+
         # Optionally create initial stock item
         try:
             initial_stock = Decimal(request.data.get('initial_stock', 0))
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index ec637412a8..0c29f1c26b 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -486,12 +486,7 @@
 
     {% if roles.part.add %}
     $("#part-duplicate").click(function() {
-        launchModalForm(
-            "{% url 'part-duplicate' part.id %}",
-            {
-                follow: true,
-            }
-        );
+        duplicatePart({{ part.pk }});
     });
     {% endif %}
 
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 0802a94f1a..53d28f7ccb 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -40,7 +40,6 @@ part_detail_urls = [
     url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
     url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
     url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
-    url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'),
     url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
     url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
 
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 3e4b6c59d7..c4ae2aee77 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -314,130 +314,6 @@ class MakePartVariant(AjaxCreateView):
         return initials
 
 
-class PartDuplicate(AjaxCreateView):
-    """ View for duplicating an existing Part object.
-
-    - Part <pk> is provided in the URL '/part/<pk>/copy/'
-    - Option for 'deep-copy' which will duplicate all BOM items (default = True)
-    """
-
-    model = Part
-    form_class = part_forms.EditPartForm
-
-    ajax_form_title = _("Duplicate Part")
-    ajax_template_name = "part/copy_part.html"
-
-    def get_data(self):
-        return {
-            'success': _('Copied part')
-        }
-
-    def get_part_to_copy(self):
-        try:
-            return Part.objects.get(id=self.kwargs['pk'])
-        except (Part.DoesNotExist, ValueError):
-            return None
-
-    def get_context_data(self):
-        return {
-            'part': self.get_part_to_copy()
-        }
-
-    def get_form(self):
-        form = super(AjaxCreateView, self).get_form()
-
-        # Force display of the 'bom_copy' widget
-        form.fields['bom_copy'].widget = CheckboxInput()
-
-        # Force display of the 'parameters_copy' widget
-        form.fields['parameters_copy'].widget = CheckboxInput()
-
-        return form
-
-    def post(self, request, *args, **kwargs):
-        """ Capture the POST request for part duplication
-
-        - If the bom_copy object is set, copy all the BOM items too!
-        - If the parameters_copy object is set, copy all the parameters too!
-        """
-
-        form = self.get_form()
-
-        context = self.get_context_data()
-
-        valid = form.is_valid()
-
-        name = request.POST.get('name', None)
-
-        if name:
-            matches = match_part_names(name)
-
-            if len(matches) > 0:
-                # Display the first five closest matches
-                context['matches'] = matches[:5]
-
-                # Enforce display of the checkbox
-                form.fields['confirm_creation'].widget = CheckboxInput()
-
-                # Check if the user has checked the 'confirm_creation' input
-                confirmed = str2bool(request.POST.get('confirm_creation', False))
-
-                if not confirmed:
-                    msg = _('Possible matches exist - confirm creation of new part')
-                    form.add_error('confirm_creation', msg)
-                    form.pre_form_warning = msg
-                    valid = False
-
-        data = {
-            'form_valid': valid
-        }
-
-        if valid:
-            # Create the new Part
-            part = form.save(commit=False)
-
-            part.creation_user = request.user
-            part.save()
-
-            data['pk'] = part.pk
-            data['text'] = str(part)
-
-            bom_copy = str2bool(request.POST.get('bom_copy', False))
-            parameters_copy = str2bool(request.POST.get('parameters_copy', False))
-
-            original = self.get_part_to_copy()
-
-            if original:
-                part.deep_copy(original, bom=bom_copy, parameters=parameters_copy)
-
-            try:
-                data['url'] = part.get_absolute_url()
-            except AttributeError:
-                pass
-
-        if valid:
-            pass
-
-        return self.renderJsonResponse(request, form, data, context=context)
-
-    def get_initial(self):
-        """ Get initial data based on the Part to be copied from.
-        """
-
-        part = self.get_part_to_copy()
-
-        if part:
-            initials = model_to_dict(part)
-        else:
-            initials = super(AjaxCreateView, self).get_initial()
-
-        initials['bom_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_BOM', True))
-
-        initials['parameters_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_PARAMETERS', True))
-
-        return initials
-
-
 class PartImport(FileManagementFormView):
     ''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
     permission_required = 'part.add'
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index f8b410c9c0..a1d40f7bf4 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -110,6 +110,19 @@ function partFields(options={}) {
             html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
         };
 
+        fields.copy_from = {
+            type: 'integer',
+            hidden: true,
+            value: options.duplicate,
+        },
+
+        fields.copy_image = {
+            type: 'boolean',
+            label: '{% trans "Copy Image" %}',
+            help_text: '{% trans "Copy image from original part" %}',
+            value: true,
+        },
+
         fields.copy_bom = {
             type: 'boolean',
             label: '{% trans "Copy BOM" %}',
@@ -184,7 +197,7 @@ function duplicatePart(pk, options={}) {
         success: function(response) {
             
             var fields = partFields({
-                duplicate: true
+                duplicate: pk,
             });
             
             constructForm('{% url "api-part-list" %}', {

From aa4ed9feb07c1f32ab45e99f3d0de8e6aa2870ee Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 5 Aug 2021 00:24:38 +1000
Subject: [PATCH 10/16] Refactor MakeVariant form

- Now is essentially identical to the DuplicatePart form
- Uses the API form structure
---
 InvenTree/part/forms.py                   | 76 ---------------------
 InvenTree/part/templates/part/detail.html |  7 +-
 InvenTree/part/test_views.py              | 19 ------
 InvenTree/part/urls.py                    |  2 +-
 InvenTree/part/views.py                   | 81 -----------------------
 InvenTree/templates/js/translated/part.js | 12 +++-
 6 files changed, 15 insertions(+), 182 deletions(-)

diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 1fc2848440..f5d7d39266 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -177,82 +177,6 @@ class SetPartCategoryForm(forms.Form):
     part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
 
 
-class EditPartForm(HelperForm):
-    """
-    Form for editing a Part object.
-    """
-
-    field_prefix = {
-        'keywords': 'fa-key',
-        'link': 'fa-link',
-        'IPN': 'fa-hashtag',
-        'default_expiry': 'fa-stopwatch',
-    }
-
-    bom_copy = forms.BooleanField(required=False,
-                                  initial=True,
-                                  help_text=_("Duplicate all BOM data for this part"),
-                                  label=_('Copy BOM'),
-                                  widget=forms.HiddenInput())
-
-    parameters_copy = forms.BooleanField(required=False,
-                                         initial=True,
-                                         help_text=_("Duplicate all parameter data for this part"),
-                                         label=_('Copy Parameters'),
-                                         widget=forms.HiddenInput())
-
-    confirm_creation = forms.BooleanField(required=False,
-                                          initial=False,
-                                          help_text=_('Confirm part creation'),
-                                          widget=forms.HiddenInput())
-
-    selected_category_templates = forms.BooleanField(required=False,
-                                                     initial=False,
-                                                     label=_('Include category parameter templates'),
-                                                     widget=forms.HiddenInput())
-
-    parent_category_templates = forms.BooleanField(required=False,
-                                                   initial=False,
-                                                   label=_('Include parent categories parameter templates'),
-                                                   widget=forms.HiddenInput())
-
-    initial_stock = forms.IntegerField(required=False,
-                                       initial=0,
-                                       label=_('Initial stock amount'),
-                                       help_text=_('Create stock for this part'))
-
-    class Meta:
-        model = Part
-        fields = [
-            'confirm_creation',
-            'category',
-            'selected_category_templates',
-            'parent_category_templates',
-            'name',
-            'IPN',
-            'description',
-            'revision',
-            'bom_copy',
-            'parameters_copy',
-            'keywords',
-            'variant_of',
-            'link',
-            'default_location',
-            'default_supplier',
-            'default_expiry',
-            'units',
-            'minimum_stock',
-            'initial_stock',
-            'component',
-            'assembly',
-            'is_template',
-            'trackable',
-            'purchaseable',
-            'salable',
-            'virtual',
-        ]
-
-
 class EditPartParameterTemplateForm(HelperForm):
     """ Form for editing a PartParameterTemplate object """
 
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 267b880d49..165ea37e66 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -525,10 +525,11 @@
     loadPartVariantTable($('#variants-table'), {{ part.pk }});
 
     $('#new-variant').click(function() {
-        launchModalForm(
-            "{% url 'make-part-variant' part.id %}",
+
+        duplicatePart(
+            {{ part.pk}},
             {
-                follow: true,
+                variant: true,
             }
         );
     });
diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py
index c555687183..5f2a9b1583 100644
--- a/InvenTree/part/test_views.py
+++ b/InvenTree/part/test_views.py
@@ -155,25 +155,6 @@ class PartDetailTest(PartViewTestCase):
         self.assertIn('streaming_content', dir(response))
 
 
-class PartTests(PartViewTestCase):
-    """ Tests for Part forms """
-
-    def test_part_duplicate(self):
-        """ Launch form to duplicate part """
-
-        # First try with an invalid part
-        response = self.client.get(reverse('part-duplicate', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
-        response = self.client.get(reverse('part-duplicate', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
-    def test_make_variant(self):
-
-        response = self.client.get(reverse('make-part-variant', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertEqual(response.status_code, 200)
-
-
 class PartRelatedTests(PartViewTestCase):
 
     def test_valid_create(self):
diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py
index 53d28f7ccb..13fc6f7c16 100644
--- a/InvenTree/part/urls.py
+++ b/InvenTree/part/urls.py
@@ -40,7 +40,7 @@ part_detail_urls = [
     url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'),
     url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'),
     url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'),
-    url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'),
+    
     url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'),
 
     url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'),
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index c4ae2aee77..e805e8f260 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -233,87 +233,6 @@ class PartSetCategory(AjaxUpdateView):
         return ctx
 
 
-class MakePartVariant(AjaxCreateView):
-    """ View for creating a new variant based on an existing template Part
-
-    - Part <pk> is provided in the URL '/part/<pk>/make_variant/'
-    - Automatically copy relevent data (BOM, etc, etc)
-
-    """
-
-    model = Part
-    form_class = part_forms.EditPartForm
-
-    ajax_form_title = _('Create Variant')
-    ajax_template_name = 'part/variant_part.html'
-
-    def get_part_template(self):
-        return get_object_or_404(Part, id=self.kwargs['pk'])
-
-    def get_context_data(self):
-        return {
-            'part': self.get_part_template(),
-        }
-
-    def get_form(self):
-        form = super(AjaxCreateView, self).get_form()
-
-        # Hide some variant-related fields
-        # form.fields['variant_of'].widget = HiddenInput()
-
-        # Force display of the 'bom_copy' widget
-        form.fields['bom_copy'].widget = CheckboxInput()
-
-        # Force display of the 'parameters_copy' widget
-        form.fields['parameters_copy'].widget = CheckboxInput()
-
-        return form
-
-    def post(self, request, *args, **kwargs):
-
-        form = self.get_form()
-        context = self.get_context_data()
-        part_template = self.get_part_template()
-
-        valid = form.is_valid()
-
-        data = {
-            'form_valid': valid,
-        }
-
-        if valid:
-            # Create the new part variant
-            part = form.save(commit=False)
-            part.variant_of = part_template
-            part.is_template = False
-
-            part.save()
-
-            data['pk'] = part.pk
-            data['text'] = str(part)
-            data['url'] = part.get_absolute_url()
-
-            bom_copy = str2bool(request.POST.get('bom_copy', False))
-            parameters_copy = str2bool(request.POST.get('parameters_copy', False))
-
-            # Copy relevent information from the template part
-            part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy)
-
-        return self.renderJsonResponse(request, form, data, context=context)
-
-    def get_initial(self):
-
-        part_template = self.get_part_template()
-
-        initials = model_to_dict(part_template)
-        initials['is_template'] = False
-        initials['variant_of'] = part_template
-        initials['bom_copy'] = InvenTreeSetting.get_setting('PART_COPY_BOM')
-        initials['parameters_copy'] = InvenTreeSetting.get_setting('PART_COPY_PARAMETERS')
-
-        return initials
-
-
 class PartImport(FileManagementFormView):
     ''' Part: Upload file, match to fields and import parts(using multi-Step form) '''
     permission_required = 'part.add'
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index a1d40f7bf4..3def7abdad 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -189,22 +189,30 @@ function editPart(pk, options={}) {
 }
 
 
+// Launch form to duplicate a part
 function duplicatePart(pk, options={}) {
 
     // First we need all the part information
     inventreeGet(`/api/part/${pk}/`, {}, {
 
-        success: function(response) {
+        success: function(data) {
             
             var fields = partFields({
                 duplicate: pk,
             });
+
+            // If we are making a "variant" part
+            if (options.variant) {
+
+                // Override the "variant_of" field
+                data.variant_of = pk;
+            }
             
             constructForm('{% url "api-part-list" %}', {
                 method: 'POST',
                 fields: fields,
                 title: '{% trans "Duplicate Part" %}',
-                data: response,
+                data: data,
                 onSuccess: function(data) {
                     // Follow the new part
                     location.href = `/part/${data.pk}/`;

From dd78464a749c51d44f588f320ce7ad9f196a7c59 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 5 Aug 2021 00:25:47 +1000
Subject: [PATCH 11/16] remove unused function

---
 InvenTree/part/models.py    | 51 -------------------------------------
 InvenTree/part/test_part.py |  8 +-----
 InvenTree/part/views.py     |  4 +--
 3 files changed, 2 insertions(+), 61 deletions(-)

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index b75edde9cc..89e92115ca 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -235,57 +235,6 @@ def rename_part_image(instance, filename):
     return os.path.join(base, fname)
 
 
-def match_part_names(match, threshold=80, reverse=True, compare_length=False):
-    """ Return a list of parts whose name matches the search term using fuzzy search.
-
-    Args:
-        match: Term to match against
-        threshold: Match percentage that must be exceeded (default = 65)
-        reverse: Ordering for search results (default = True - highest match is first)
-        compare_length: Include string length checks
-
-    Returns:
-        A sorted dict where each element contains the following key:value pairs:
-            - 'part' : The matched part
-            - 'ratio' : The matched ratio
-    """
-
-    match = str(match).strip().lower()
-
-    if len(match) == 0:
-        return []
-
-    parts = Part.objects.all()
-
-    matches = []
-
-    for part in parts:
-        compare = str(part.name).strip().lower()
-
-        if len(compare) == 0:
-            continue
-
-        ratio = fuzz.partial_token_sort_ratio(compare, match)
-
-        if compare_length:
-            # Also employ primitive length comparison
-            # TODO - Improve this somewhat...
-            l_min = min(len(match), len(compare))
-            l_max = max(len(match), len(compare))
-
-            ratio *= (l_min / l_max)
-
-        if ratio >= threshold:
-            matches.append({
-                'part': part,
-                'ratio': round(ratio, 1)
-            })
-
-    matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse)
-
-    return matches
-
-
 class PartManager(TreeManager):
     """
     Defines a custom object manager for the Part model.
diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index e30c80549f..b32b30a22e 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError
 import os
 
 from .models import Part, PartCategory, PartTestTemplate
-from .models import rename_part_image, match_part_names
+from .models import rename_part_image
 from .templatetags import inventree_extras
 
 import part.settings
@@ -163,12 +163,6 @@ class PartTest(TestCase):
     def test_copy(self):
         self.r2.deep_copy(self.r1, image=True, bom=True)
 
-    def test_match_names(self):
-
-        matches = match_part_names('M2x5 LPHS')
-
-        self.assertTrue(len(matches) > 0)
-
     def test_sell_pricing(self):
         # check that the sell pricebreaks were loaded
         self.assertTrue(self.r1.has_price_breaks)
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index e805e8f260..0e06678694 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -14,8 +14,7 @@ from django.shortcuts import HttpResponseRedirect
 from django.utils.translation import gettext_lazy as _
 from django.urls import reverse
 from django.views.generic import DetailView, ListView
-from django.forms.models import model_to_dict
-from django.forms import HiddenInput, CheckboxInput
+from django.forms import HiddenInput
 from django.conf import settings
 from django.contrib import messages
 
@@ -35,7 +34,6 @@ from .models import PartCategory, Part, PartRelated
 from .models import PartParameterTemplate
 from .models import PartCategoryParameterTemplate
 from .models import BomItem
-from .models import match_part_names
 from .models import PartSellPriceBreak, PartInternalPriceBreak
 
 from common.models import InvenTreeSetting

From aaf394ca7a1698ea6839fb7511485a1f85480f9f Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 5 Aug 2021 00:26:21 +1000
Subject: [PATCH 12/16] PEP fixes

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

diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 89e92115ca..28fd3ce793 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -34,7 +34,6 @@ from stdimage.models import StdImageField
 
 from decimal import Decimal, InvalidOperation
 from datetime import datetime
-from rapidfuzz import fuzz
 import hashlib
 
 from InvenTree import helpers

From 6acff2a26e8f6a578207d16ef41b61bf1b04f415 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 5 Aug 2021 00:40:02 +1000
Subject: [PATCH 13/16] Fixes unit test

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

diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index b32b30a22e..1e831601a4 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -287,7 +287,7 @@ class PartSettingsTest(TestCase):
         part = self.make_part()
 
         self.assertTrue(part.component)
-        self.assertFalse(part.purchaseable)
+        self.assertTrue(part.purchaseable)
         self.assertFalse(part.salable)
         self.assertFalse(part.trackable)
 

From 655e5692e98d55c12dd122d8513ef3346199e9da Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 5 Aug 2021 00:58:07 +1000
Subject: [PATCH 14/16] More unit test fixes

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

diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index 1e831601a4..1bd9fdf87d 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -275,7 +275,7 @@ class PartSettingsTest(TestCase):
         """
 
         self.assertTrue(part.settings.part_component_default())
-        self.assertFalse(part.settings.part_purchaseable_default())
+        self.assertTrue(part.settings.part_purchaseable_default())
         self.assertFalse(part.settings.part_salable_default())
         self.assertFalse(part.settings.part_trackable_default())
 

From c7712d4235ef06ade7baf29f40bfdc77706dc859 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Thu, 5 Aug 2021 01:13:48 +1000
Subject: [PATCH 15/16] even more unit tests

---
 InvenTree/part/test_api.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index 7700c5c61f..bbd73b73e0 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -434,8 +434,8 @@ class PartAPITest(InvenTreeAPITestCase):
         self.assertTrue(data['active'])
         self.assertFalse(data['virtual'])
 
-        # By default, parts are not purchaseable
-        self.assertFalse(data['purchaseable'])
+        # By default, parts are purchaseable
+        self.assertTrue(data['purchaseable'])
 
         # Set the default 'purchaseable' status to True
         InvenTreeSetting.set_setting(

From c0ccb8f588634f07a94209eea2c1dc58a9002c61 Mon Sep 17 00:00:00 2001
From: eeintech <eeintech@eeinte.ch>
Date: Wed, 4 Aug 2021 17:11:35 -0400
Subject: [PATCH 16/16] Fixed typo for build responsible column header

---
 InvenTree/templates/js/translated/build.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 4b8cd47eb5..26f3876af3 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -927,7 +927,7 @@ function loadBuildTable(table, options) {
             },
             {
                 field: 'responsible',
-                title: '{% trans "Resposible" %}',
+                title: '{% trans "Responsible" %}',
                 sortable: true,
                 formatter: function(value, row, index, field) {
                     if (value)