From 1f70538b043a03168e1666b01d8fe450a58025ee Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 4 Aug 2021 14:24:17 +1000 Subject: [PATCH 01/17] Adds a button to tables to reload data --- InvenTree/templates/js/translated/filters.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InvenTree/templates/js/translated/filters.js b/InvenTree/templates/js/translated/filters.js index 4ee08affdf..bc0dc1b958 100644 --- a/InvenTree/templates/js/translated/filters.js +++ b/InvenTree/templates/js/translated/filters.js @@ -265,6 +265,8 @@ function setupFilterList(tableKey, table, target) { // One blank slate, please element.empty(); + element.append(``); + element.append(``); if (Object.keys(filters).length > 0) { @@ -279,6 +281,11 @@ function setupFilterList(tableKey, table, target) { element.append(`
${title} = ${value}x
`); } + // Callback for reloading the table + element.find(`#reload-${tableKey}`).click(function() { + $(table).bootstrapTable('refresh'); + }); + // Add a callback for adding a new filter element.find(`#${add}`).click(function clicked() { From 75a1be0284244e79ba08d986c2957d9e852d4955 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 17:25:51 +1000 Subject: [PATCH 02/17] 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\d+)/', include(part_bom_urls)), - # Individual part using IPN as slug url(r'^(?P[-\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 Date: Wed, 4 Aug 2021 17:41:47 +1000 Subject: [PATCH 03/17] 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 Date: Wed, 4 Aug 2021 23:16:11 +1000 Subject: [PATCH 04/17] 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 = `
`; + var html = ''; + + // Optional content to render before the field + if (parameters.before) { + html += parameters.before; + } + + html += `
`; // Add a label html += constructLabel(name, parameters); @@ -1352,6 +1373,10 @@ function constructField(name, parameters, options) { html += `
`; // controls html += `
`; // 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 Date: Wed, 4 Aug 2021 23:26:17 +1000 Subject: [PATCH 05/17] 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: `

{% trans "Part Attributes" %}


` + }, + 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: `

{% trans "Part Creation Options" %}


`, + }; + + 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: `

{% trans "Part Duplication Options" %}


`, + }; + + 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 Date: Wed, 4 Aug 2021 23:27:16 +1000 Subject: [PATCH 06/17] 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 Date: Wed, 4 Aug 2021 23:29:39 +1000 Subject: [PATCH 07/17] 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 Date: Wed, 4 Aug 2021 23:48:21 +1000 Subject: [PATCH 08/17] 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 Date: Thu, 5 Aug 2021 00:15:55 +1000 Subject: [PATCH 09/17] 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 Date: Thu, 5 Aug 2021 00:16:42 +1000 Subject: [PATCH 10/17] 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 is provided in the URL '/part//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: `

{% trans "Part Duplication Options" %}


`, }; + 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 Date: Thu, 5 Aug 2021 00:24:38 +1000 Subject: [PATCH 11/17] 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 is provided in the URL '/part//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 Date: Thu, 5 Aug 2021 00:25:47 +1000 Subject: [PATCH 12/17] 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 Date: Thu, 5 Aug 2021 00:26:21 +1000 Subject: [PATCH 13/17] 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 Date: Thu, 5 Aug 2021 00:40:02 +1000 Subject: [PATCH 14/17] 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 Date: Thu, 5 Aug 2021 00:58:07 +1000 Subject: [PATCH 15/17] 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 Date: Thu, 5 Aug 2021 01:13:48 +1000 Subject: [PATCH 16/17] 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 Date: Wed, 4 Aug 2021 17:11:35 -0400 Subject: [PATCH 17/17] 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)