Merge pull request #1956 from SchrodingersGat/supplier-part-from-form

Supplier part from form
This commit is contained in:
Oliver 2021-08-14 12:12:09 +10:00 committed by GitHub
commit 1db654e990
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 506 additions and 80 deletions

View File

@ -730,6 +730,13 @@
padding: 10px; padding: 10px;
} }
.form-panel {
border-radius: 5px;
border: 1px solid #ccc;
padding: 5px;
}
.modal input { .modal input {
width: 100%; width: 100%;
} }

View File

@ -9,12 +9,14 @@ from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
from django.http import JsonResponse from django.http import JsonResponse
from django.db.models import Q, F, Count, Min, Max, Avg from django.db.models import Q, F, Count, Min, Max, Avg
from django.db import transaction
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import filters, serializers from rest_framework import filters, serializers
from rest_framework import generics from rest_framework import generics
from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
@ -23,7 +25,7 @@ from djmoney.money import Money
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory, BomItem from .models import Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartSellPriceBreak, PartInternalPriceBreak
from .models import PartCategoryParameterTemplate from .models import PartCategoryParameterTemplate
from stock.models import StockItem from company.models import Company, ManufacturerPart, SupplierPart
from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from build.models import Build from build.models import Build
@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
else: else:
return Response(data) return Response(data)
@transaction.atomic
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
""" """
We wish to save the user who created this part! We wish to save the user who created this part!
@ -637,6 +643,8 @@ class PartList(generics.ListCreateAPIView):
Note: Implementation copied from DRF class CreateModelMixin Note: Implementation copied from DRF class CreateModelMixin
""" """
# TODO: Unit tests for this function!
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
pass pass
# Optionally create initial stock item # Optionally create initial stock item
try: initial_stock = str2bool(request.data.get('initial_stock', False))
initial_stock = Decimal(request.data.get('initial_stock', 0))
if initial_stock > 0 and part.default_location is not None: if initial_stock:
try:
stock_item = StockItem( initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', ''))
if initial_stock_quantity <= 0:
raise ValidationError({
'initial_stock_quantity': [_('Must be greater than zero')],
})
except (ValueError, InvalidOperation): # Invalid quantity provided
raise ValidationError({
'initial_stock_quantity': [_('Must be a valid quantity')],
})
initial_stock_location = request.data.get('initial_stock_location', None)
try:
initial_stock_location = StockLocation.objects.get(pk=initial_stock_location)
except (ValueError, StockLocation.DoesNotExist):
initial_stock_location = None
if initial_stock_location is None:
if part.default_location is not None:
initial_stock_location = part.default_location
else:
raise ValidationError({
'initial_stock_location': [_('Specify location for initial part stock')],
})
stock_item = StockItem(
part=part,
quantity=initial_stock_quantity,
location=initial_stock_location,
)
stock_item.save(user=request.user)
# Optionally add manufacturer / supplier data to the part
if part.purchaseable and str2bool(request.data.get('add_supplier_info', False)):
try:
manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None))
except:
manufacturer = None
try:
supplier = Company.objects.get(pk=request.data.get('supplier', None))
except:
supplier = None
mpn = str(request.data.get('MPN', '')).strip()
sku = str(request.data.get('SKU', '')).strip()
# Construct a manufacturer part
if manufacturer or mpn:
if not manufacturer:
raise ValidationError({
'manufacturer': [_("This field is required")]
})
if not mpn:
raise ValidationError({
'MPN': [_("This field is required")]
})
manufacturer_part = ManufacturerPart.objects.create(
part=part, part=part,
quantity=initial_stock, manufacturer=manufacturer,
location=part.default_location, MPN=mpn
) )
else:
# No manufacturer part data specified
manufacturer_part = None
stock_item.save(user=request.user) if supplier or sku:
if not supplier:
raise ValidationError({
'supplier': [_("This field is required")]
})
if not sku:
raise ValidationError({
'SKU': [_("This field is required")]
})
except: SupplierPart.objects.create(
pass part=part,
supplier=supplier,
SKU=sku,
manufacturer_part=manufacturer_part,
)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)

View File

@ -276,6 +276,7 @@
constructForm('{% url "api-part-list" %}', { constructForm('{% url "api-part-list" %}', {
method: 'POST', method: 'POST',
fields: fields, fields: fields,
groups: partGroups(),
title: '{% trans "Create Part" %}', title: '{% trans "Create Part" %}',
onSuccess: function(data) { onSuccess: function(data) {
// Follow the new part // Follow the new part

View File

@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
'location', 'location',
'bom', 'bom',
'test_templates', 'test_templates',
'company',
] ]
roles = [ roles = [
@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertFalse(response.data['active']) self.assertFalse(response.data['active'])
self.assertFalse(response.data['purchaseable']) self.assertFalse(response.data['purchaseable'])
def test_initial_stock(self):
"""
Tests for initial stock quantity creation
"""
url = reverse('api-part-list')
# Track how many parts exist at the start of this test
n = Part.objects.count()
# Set up required part data
data = {
'category': 1,
'name': "My lil' test part",
'description': 'A part with which to test',
}
# Signal that we want to add initial stock
data['initial_stock'] = True
# Post without a quantity
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_quantity', response.data)
# Post with an invalid quantity
data['initial_stock_quantity'] = "ax"
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_quantity', response.data)
# Post with a negative quantity
data['initial_stock_quantity'] = -1
response = self.post(url, data, expected_code=400)
self.assertIn('Must be greater than zero', response.data['initial_stock_quantity'])
# Post with a valid quantity
data['initial_stock_quantity'] = 12345
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_location', response.data)
# Check that the number of parts has not increased (due to form failures)
self.assertEqual(Part.objects.count(), n)
# Now, set a location
data['initial_stock_location'] = 1
response = self.post(url, data, expected_code=201)
# Check that the part has been created
self.assertEqual(Part.objects.count(), n + 1)
pk = response.data['pk']
new_part = Part.objects.get(pk=pk)
self.assertEqual(new_part.total_stock, 12345)
def test_initial_supplier_data(self):
"""
Tests for initial creation of supplier / manufacturer data
"""
url = reverse('api-part-list')
n = Part.objects.count()
# Set up initial part data
data = {
'category': 1,
'name': 'Buy Buy Buy',
'description': 'A purchaseable part',
'purchaseable': True,
}
# Signal that we wish to create initial supplier data
data['add_supplier_info'] = True
# Specify MPN but not manufacturer
data['MPN'] = 'MPN-123'
response = self.post(url, data, expected_code=400)
self.assertIn('manufacturer', response.data)
# Specify manufacturer but not MPN
del data['MPN']
data['manufacturer'] = 1
response = self.post(url, data, expected_code=400)
self.assertIn('MPN', response.data)
# Specify SKU but not supplier
del data['manufacturer']
data['SKU'] = 'SKU-123'
response = self.post(url, data, expected_code=400)
self.assertIn('supplier', response.data)
# Specify supplier but not SKU
del data['SKU']
data['supplier'] = 1
response = self.post(url, data, expected_code=400)
self.assertIn('SKU', response.data)
# Check that no new parts have been created
self.assertEqual(Part.objects.count(), n)
# Now, fully specify the details
data['SKU'] = 'SKU-123'
data['supplier'] = 3
data['MPN'] = 'MPN-123'
data['manufacturer'] = 6
response = self.post(url, data, expected_code=201)
self.assertEqual(Part.objects.count(), n + 1)
pk = response.data['pk']
new_part = Part.objects.get(pk=pk)
# Check that there is a new manufacturer part *and* a new supplier part
self.assertEqual(new_part.supplier_parts.count(), 1)
self.assertEqual(new_part.manufacturer_parts.count(), 1)
class PartDetailTests(InvenTreeAPITestCase): class PartDetailTests(InvenTreeAPITestCase):
""" """

View File

@ -264,6 +264,10 @@ function constructForm(url, options) {
// Default HTTP method // Default HTTP method
options.method = options.method || 'PATCH'; options.method = options.method || 'PATCH';
// Default "groups" definition
options.groups = options.groups || {};
options.current_group = null;
// Construct an "empty" data object if not provided // Construct an "empty" data object if not provided
if (!options.data) { if (!options.data) {
options.data = {}; options.data = {};
@ -362,6 +366,14 @@ function constructFormBody(fields, options) {
} }
} }
// Initialize an "empty" field for each specified field
for (field in displayed_fields) {
if (!(field in fields)) {
console.log("adding blank field for ", field);
fields[field] = {};
}
}
// Provide each field object with its own name // Provide each field object with its own name
for(field in fields) { for(field in fields) {
fields[field].name = field; fields[field].name = field;
@ -379,52 +391,18 @@ function constructFormBody(fields, options) {
// Override existing query filters (if provided!) // Override existing query filters (if provided!)
fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters); fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
// TODO: Refactor the following code with Object.assign (see above) for (var opt in field_options) {
// "before" and "after" renders var val = field_options[opt];
fields[field].before = field_options.before;
fields[field].after = field_options.after;
// Secondary modal options if (opt == 'filters') {
fields[field].secondary = field_options.secondary; // ignore filters (see above)
} else if (opt == 'icon') {
// Edit callback // Specify custom icon
fields[field].onEdit = field_options.onEdit; fields[field].prefix = `<span class='fas ${val}'></span>`;
} else {
fields[field].multiline = field_options.multiline; fields[field][opt] = field_options[opt];
}
// Custom help_text
if (field_options.help_text) {
fields[field].help_text = field_options.help_text;
}
// Custom label
if (field_options.label) {
fields[field].label = field_options.label;
}
// Custom placeholder
if (field_options.placeholder) {
fields[field].placeholder = field_options.placeholder;
}
// Choices
if (field_options.choices) {
fields[field].choices = field_options.choices;
}
// Field prefix
if (field_options.prefix) {
fields[field].prefix = field_options.prefix;
} else if (field_options.icon) {
// Specify icon like 'fa-user'
fields[field].prefix = `<span class='fas ${field_options.icon}'></span>`;
}
fields[field].hidden = field_options.hidden;
if (field_options.read_only != null) {
fields[field].read_only = field_options.read_only;
} }
} }
} }
@ -465,8 +443,10 @@ function constructFormBody(fields, options) {
html += constructField(name, field, options); html += constructField(name, field, options);
} }
// TODO: Dynamically create the modals, if (options.current_group) {
// so that we can have an infinite number of stacks! // Close out the current group
html += `</div></div>`;
}
// Create a new modal if one does not exists // Create a new modal if one does not exists
if (!options.modal) { if (!options.modal) {
@ -535,6 +515,8 @@ function constructFormBody(fields, options) {
submitFormData(fields, options); submitFormData(fields, options);
} }
}); });
initializeGroups(fields, options);
} }
@ -860,9 +842,12 @@ function handleFormErrors(errors, fields, options) {
var non_field_errors = $(options.modal).find('#non-field-errors'); var non_field_errors = $(options.modal).find('#non-field-errors');
// TODO: Display the JSON error text when hovering over the "info" icon
non_field_errors.append( non_field_errors.append(
`<div class='alert alert-block alert-danger'> `<div class='alert alert-block alert-danger'>
<b>{% trans "Form errors exist" %}</b> <b>{% trans "Form errors exist" %}</b>
<span id='form-errors-info' class='float-right fas fa-info-circle icon-red'>
</span>
</div>` </div>`
); );
@ -932,7 +917,10 @@ function addFieldCallbacks(fields, options) {
function addFieldCallback(name, field, options) { function addFieldCallback(name, field, options) {
$(options.modal).find(`#id_${name}`).change(function() { $(options.modal).find(`#id_${name}`).change(function() {
field.onEdit(name, field, options);
var value = getFormFieldValue(name, field, options);
field.onEdit(value, name, field, options);
}); });
} }
@ -960,6 +948,71 @@ function addClearCallback(name, field, options) {
} }
// Initialize callbacks and initial states for groups
function initializeGroups(fields, options) {
var modal = options.modal;
// Callback for when the group is expanded
$(modal).find('.form-panel-content').on('show.bs.collapse', function() {
var panel = $(this).closest('.form-panel');
var group = panel.attr('group');
var icon = $(modal).find(`#group-icon-${group}`);
icon.removeClass('fa-angle-right');
icon.addClass('fa-angle-up');
});
// Callback for when the group is collapsed
$(modal).find('.form-panel-content').on('hide.bs.collapse', function() {
var panel = $(this).closest('.form-panel');
var group = panel.attr('group');
var icon = $(modal).find(`#group-icon-${group}`);
icon.removeClass('fa-angle-up');
icon.addClass('fa-angle-right');
});
// Set initial state of each specified group
for (var group in options.groups) {
var group_options = options.groups[group];
if (group_options.collapsed) {
$(modal).find(`#form-panel-content-${group}`).collapse("hide");
} else {
$(modal).find(`#form-panel-content-${group}`).collapse("show");
}
if (group_options.hidden) {
hideFormGroup(group, options);
}
}
}
// Hide a form group
function hideFormGroup(group, options) {
$(options.modal).find(`#form-panel-${group}`).hide();
}
// Show a form group
function showFormGroup(group, options) {
$(options.modal).find(`#form-panel-${group}`).show();
}
function setFormGroupVisibility(group, vis, options) {
if (vis) {
showFormGroup(group, options);
} else {
hideFormGroup(group, options);
}
}
function initializeRelatedFields(fields, options) { function initializeRelatedFields(fields, options) {
var field_names = options.field_names; var field_names = options.field_names;
@ -1353,6 +1406,8 @@ function renderModelData(name, model, data, parameters, options) {
*/ */
function constructField(name, parameters, options) { function constructField(name, parameters, options) {
var html = '';
// Shortcut for simple visual fields // Shortcut for simple visual fields
if (parameters.type == 'candy') { if (parameters.type == 'candy') {
return constructCandyInput(name, parameters, options); return constructCandyInput(name, parameters, options);
@ -1365,13 +1420,58 @@ function constructField(name, parameters, options) {
return constructHiddenInput(name, parameters, options); return constructHiddenInput(name, parameters, options);
} }
// Are we ending a group?
if (options.current_group && parameters.group != options.current_group) {
html += `</div></div>`;
// Null out the current "group" so we can start a new one
options.current_group = null;
}
// Are we starting a new group?
if (parameters.group) {
var group = parameters.group;
var group_options = options.groups[group] || {};
// Are we starting a new group?
// Add HTML for the start of a separate panel
if (parameters.group != options.current_group) {
html += `
<div class='panel form-panel' id='form-panel-${group}' group='${group}'>
<div class='panel-heading form-panel-heading' id='form-panel-heading-${group}'>`;
if (group_options.collapsible) {
html += `
<div data-toggle='collapse' data-target='#form-panel-content-${group}'>
<a href='#'><span id='group-icon-${group}' class='fas fa-angle-up'></span>
`;
} else {
html += `<div>`;
}
html += `<h4 style='display: inline;'>${group_options.title || group}</h4>`;
if (group_options.collapsible) {
html += `</a>`;
}
html += `
</div></div>
<div class='panel-content form-panel-content' id='form-panel-content-${group}'>
`;
}
// Keep track of the group we are in
options.current_group = group;
}
var form_classes = 'form-group'; var form_classes = 'form-group';
if (parameters.errors) { if (parameters.errors) {
form_classes += ' has-error'; form_classes += ' has-error';
} }
var html = '';
// Optional content to render before the field // Optional content to render before the field
if (parameters.before) { if (parameters.before) {
@ -1428,13 +1528,14 @@ function constructField(name, parameters, options) {
html += `</div>`; // input-group html += `</div>`; // input-group
} }
// Div for error messages
html += `<div id='errors-${name}'></div>`;
if (parameters.help_text) { if (parameters.help_text) {
html += constructHelpText(name, parameters, options); html += constructHelpText(name, parameters, options);
} }
// Div for error messages
html += `<div id='errors-${name}'></div>`;
html += `</div>`; // controls html += `</div>`; // controls
html += `</div>`; // form-group html += `</div>`; // form-group
@ -1599,6 +1700,10 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`placeholder='${parameters.placeholder}'`); opts.push(`placeholder='${parameters.placeholder}'`);
} }
if (parameters.type == 'boolean') {
opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`);
}
if (parameters.multiline) { if (parameters.multiline) {
return `<textarea ${opts.join(' ')}></textarea>`; return `<textarea ${opts.join(' ')}></textarea>`;
} else { } else {
@ -1772,7 +1877,13 @@ function constructCandyInput(name, parameters, options) {
*/ */
function constructHelpText(name, parameters, options) { function constructHelpText(name, parameters, options) {
var html = `<div id='hint_id_${name}' class='help-block'><i>${parameters.help_text}</i></div>`; var style = '';
if (parameters.type == 'boolean') {
style = `style='display: inline-block; margin-left: 25px' `;
}
var html = `<div id='hint_id_${name}' ${style}class='help-block'><i>${parameters.help_text}</i></div>`;
return html; return html;
} }

View File

@ -13,6 +13,31 @@ function yesNoLabel(value) {
} }
} }
function partGroups(options={}) {
return {
attributes: {
title: '{% trans "Part Attributes" %}',
collapsible: true,
},
create: {
title: '{% trans "Part Creation Options" %}',
collapsible: true,
},
duplicate: {
title: '{% trans "Part Duplication Options" %}',
collapsible: true,
},
supplier: {
title: '{% trans "Supplier Options" %}',
collapsible: true,
hidden: !global_settings.PART_PURCHASEABLE,
}
}
}
// Construct fieldset for part forms // Construct fieldset for part forms
function partFields(options={}) { function partFields(options={}) {
@ -48,36 +73,44 @@ function partFields(options={}) {
minimum_stock: { minimum_stock: {
icon: 'fa-boxes', icon: 'fa-boxes',
}, },
attributes: {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
},
component: { component: {
value: global_settings.PART_COMPONENT, value: global_settings.PART_COMPONENT,
group: 'attributes',
}, },
assembly: { assembly: {
value: global_settings.PART_ASSEMBLY, value: global_settings.PART_ASSEMBLY,
group: 'attributes',
}, },
is_template: { is_template: {
value: global_settings.PART_TEMPLATE, value: global_settings.PART_TEMPLATE,
group: 'attributes',
}, },
trackable: { trackable: {
value: global_settings.PART_TRACKABLE, value: global_settings.PART_TRACKABLE,
group: 'attributes',
}, },
purchaseable: { purchaseable: {
value: global_settings.PART_PURCHASEABLE, value: global_settings.PART_PURCHASEABLE,
group: 'attributes',
onEdit: function(value, name, field, options) {
setFormGroupVisibility('supplier', value, options);
}
}, },
salable: { salable: {
value: global_settings.PART_SALABLE, value: global_settings.PART_SALABLE,
group: 'attributes',
}, },
virtual: { virtual: {
value: global_settings.PART_VIRTUAL, value: global_settings.PART_VIRTUAL,
group: 'attributes',
}, },
}; };
// If editing a part, we can set the "active" status // If editing a part, we can set the "active" status
if (options.edit) { if (options.edit) {
fields.active = {}; fields.active = {
group: 'attributes'
};
} }
// Pop expiry field // Pop expiry field
@ -91,16 +124,32 @@ function partFields(options={}) {
// No supplier parts available yet // No supplier parts available yet
delete fields["default_supplier"]; 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) { if (global_settings.PART_CREATE_INITIAL) {
fields.initial_stock = { fields.initial_stock = {
type: 'boolean',
label: '{% trans "Create Initial Stock" %}',
help_text: '{% trans "Create an initial stock item for this part" %}',
group: 'create',
};
fields.initial_stock_quantity = {
type: 'decimal', type: 'decimal',
value: 1,
label: '{% trans "Initial Stock Quantity" %}', label: '{% trans "Initial Stock Quantity" %}',
help_text: '{% trans "Initialize part stock with specified quantity" %}', help_text: '{% trans "Specify initial stock quantity for this part" %}',
group: 'create',
};
// TODO - Allow initial location of stock to be specified
fields.initial_stock_location = {
label: '{% trans "Location" %}',
help_text: '{% trans "Select destination stock location" %}',
type: 'related field',
required: true,
api_url: `/api/stock/location/`,
model: 'stocklocation',
group: 'create',
}; };
} }
@ -109,21 +158,65 @@ function partFields(options={}) {
label: '{% trans "Copy Category Parameters" %}', label: '{% trans "Copy Category Parameters" %}',
help_text: '{% trans "Copy parameter templates from selected part category" %}', help_text: '{% trans "Copy parameter templates from selected part category" %}',
value: global_settings.PART_CATEGORY_PARAMETERS, value: global_settings.PART_CATEGORY_PARAMETERS,
group: 'create',
}; };
// Supplier options
fields.add_supplier_info = {
type: 'boolean',
label: '{% trans "Add Supplier Data" %}',
help_text: '{% trans "Create initial supplier data for this part" %}',
group: 'supplier',
};
fields.supplier = {
type: 'related field',
model: 'company',
label: '{% trans "Supplier" %}',
help_text: '{% trans "Select supplier" %}',
filters: {
'is_supplier': true,
},
api_url: '{% url "api-company-list" %}',
group: 'supplier',
};
fields.SKU = {
type: 'string',
label: '{% trans "SKU" %}',
help_text: '{% trans "Supplier stock keeping unit" %}',
group: 'supplier',
};
fields.manufacturer = {
type: 'related field',
model: 'company',
label: '{% trans "Manufacturer" %}',
help_text: '{% trans "Select manufacturer" %}',
filters: {
'is_manufacturer': true,
},
api_url: '{% url "api-company-list" %}',
group: 'supplier',
};
fields.MPN = {
type: 'string',
label: '{% trans "MPN" %}',
help_text: '{% trans "Manufacturer Part Number" %}',
group: 'supplier',
};
} }
// Additional fields when "duplicating" a part // Additional fields when "duplicating" a part
if (options.duplicate) { if (options.duplicate) {
fields.duplicate = {
type: 'candy',
html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
};
fields.copy_from = { fields.copy_from = {
type: 'integer', type: 'integer',
hidden: true, hidden: true,
value: options.duplicate, value: options.duplicate,
group: 'duplicate',
}, },
fields.copy_image = { fields.copy_image = {
@ -131,6 +224,7 @@ function partFields(options={}) {
label: '{% trans "Copy Image" %}', label: '{% trans "Copy Image" %}',
help_text: '{% trans "Copy image from original part" %}', help_text: '{% trans "Copy image from original part" %}',
value: true, value: true,
group: 'duplicate',
}, },
fields.copy_bom = { fields.copy_bom = {
@ -138,6 +232,7 @@ function partFields(options={}) {
label: '{% trans "Copy BOM" %}', label: '{% trans "Copy BOM" %}',
help_text: '{% trans "Copy bill of materials from original part" %}', help_text: '{% trans "Copy bill of materials from original part" %}',
value: global_settings.PART_COPY_BOM, value: global_settings.PART_COPY_BOM,
group: 'duplicate',
}; };
fields.copy_parameters = { fields.copy_parameters = {
@ -145,6 +240,7 @@ function partFields(options={}) {
label: '{% trans "Copy Parameters" %}', label: '{% trans "Copy Parameters" %}',
help_text: '{% trans "Copy parameter data from original part" %}', help_text: '{% trans "Copy parameter data from original part" %}',
value: global_settings.PART_COPY_PARAMETERS, value: global_settings.PART_COPY_PARAMETERS,
group: 'duplicate',
}; };
} }
@ -191,8 +287,11 @@ function editPart(pk, options={}) {
edit: true edit: true
}); });
var groups = partGroups({});
constructForm(url, { constructForm(url, {
fields: fields, fields: fields,
groups: partGroups(),
title: '{% trans "Edit Part" %}', title: '{% trans "Edit Part" %}',
reload: true, reload: true,
}); });
@ -221,6 +320,7 @@ function duplicatePart(pk, options={}) {
constructForm('{% url "api-part-list" %}', { constructForm('{% url "api-part-list" %}', {
method: 'POST', method: 'POST',
fields: fields, fields: fields,
groups: partGroups(),
title: '{% trans "Duplicate Part" %}', title: '{% trans "Duplicate Part" %}',
data: data, data: data,
onSuccess: function(data) { onSuccess: function(data) {