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/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html
index 5770777d28..e3119e6fdb 100644
--- a/InvenTree/build/templates/build/build_base.html
+++ b/InvenTree/build/templates/build/build_base.html
@@ -111,8 +111,8 @@ src="{% static 'img/blank_image.png' %}"
- {% trans "Install another StockItem into this item." %}
+ {% trans "Install another Stock Item into this item." %}
{% trans "Stock items can only be installed if they meet the following criteria" %}:
-
{% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}
-
{% trans "The StockItem is currently in stock" %}
+
{% trans "The Stock Item links to a Part which is in the BOM for this Stock Item" %}
+
{% trans "The Stock Item is currently in stock" %}
+
{% trans "The Stock Item is serialized and does not belong to another item" %}
+{% elif install_in %}
+
+ {% trans "Install this Stock Item in another stock item." %}
+
+
+ {% trans "Stock items can only be installed if they meet the following criteria" %}:
+
+
+
{% trans "The part associated to this Stock Item belongs to another part's BOM" %}
+
{% trans "This Stock Item is serialized and does not belong to another item" %}
+
+
+{% endif %}
+
{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 7b6fbc527e..2f602a93e1 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -536,36 +536,73 @@ class StockItemInstall(AjaxUpdateView):
part = None
+ def get_params(self):
+ """ Retrieve GET parameters """
+
+ # Look at GET params
+ self.part_id = self.request.GET.get('part', None)
+ self.install_in = self.request.GET.get('install_in', False)
+ self.install_item = self.request.GET.get('install_item', False)
+
+ if self.part_id is None:
+ # Look at POST params
+ self.part_id = self.request.POST.get('part', None)
+
+ try:
+ self.part = Part.objects.get(pk=self.part_id)
+ except (ValueError, Part.DoesNotExist):
+ self.part = None
+
def get_stock_items(self):
"""
Return a list of stock items suitable for displaying to the user.
Requirements:
- Items must be in stock
-
- Filters:
- - Items can be filtered by Part reference
+ - Items must be in BOM of stock item
+ - Items must be serialized
"""
-
+
+ # Filter items in stock
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
- # Filter by Part association
+ # Filter serialized stock items
+ items = items.exclude(serial__isnull=True).exclude(serial__exact='')
- # Look at GET params
- part_id = self.request.GET.get('part', None)
+ if self.part:
+ # Filter for parts to install this item in
+ if self.install_in:
+ # Get parts using this part
+ allowed_parts = self.part.get_used_in()
+ # Filter
+ items = items.filter(part__in=allowed_parts)
- if part_id is None:
- # Look at POST params
- part_id = self.request.POST.get('part', None)
-
- try:
- self.part = Part.objects.get(pk=part_id)
- items = items.filter(part=self.part)
- except (ValueError, Part.DoesNotExist):
- self.part = None
+ # Filter for parts to install in this item
+ if self.install_item:
+ # Get parts used in this part's BOM
+ bom_items = self.part.get_bom_items()
+ allowed_parts = [item.sub_part for item in bom_items]
+ # Filter
+ items = items.filter(part__in=allowed_parts)
return items
+ def get_context_data(self, **kwargs):
+ """ Retrieve parameters and update context """
+
+ ctx = super().get_context_data(**kwargs)
+
+ # Get request parameters
+ self.get_params()
+
+ ctx.update({
+ 'part': self.part,
+ 'install_in': self.install_in,
+ 'install_item': self.install_item,
+ })
+
+ return ctx
+
def get_initial(self):
initials = super().get_initial()
@@ -576,11 +613,16 @@ class StockItemInstall(AjaxUpdateView):
if items.count() == 1:
item = items.first()
initials['stock_item'] = item.pk
- initials['quantity_to_install'] = item.quantity
if self.part:
initials['part'] = self.part
+ try:
+ # Is this stock item being installed in the other stock item?
+ initials['to_install'] = self.install_in or not self.install_item
+ except AttributeError:
+ pass
+
return initials
def get_form(self):
@@ -593,6 +635,8 @@ class StockItemInstall(AjaxUpdateView):
def post(self, request, *args, **kwargs):
+ self.get_params()
+
form = self.get_form()
valid = form.is_valid()
@@ -602,13 +646,19 @@ class StockItemInstall(AjaxUpdateView):
data = form.cleaned_data
other_stock_item = data['stock_item']
- quantity = data['quantity_to_install']
+ # Quantity will always be 1 for serialized item
+ quantity = 1
notes = data['notes']
- # Install the other stock item into this one
+ # Get stock item
this_stock_item = self.get_object()
- this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
+ if data['to_install']:
+ # Install this stock item into the other stock item
+ other_stock_item.installStockItem(this_stock_item, quantity, request.user, notes)
+ else:
+ # Install the other stock item into this one
+ this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
data = {
'form_valid': valid,
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index 32166d972a..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');
@@ -262,13 +282,13 @@ function loadBomTable(table, options) {
cols.push(
{
field: 'price_range',
- title: '{% trans "Buy Price" %}',
+ title: '{% trans "Supplier Cost" %}',
sortable: true,
formatter: function(value, row, index, field) {
if (value) {
return value;
} else {
- return "{% trans 'No pricing available' %}";
+ return "{% trans 'No supplier pricing available' %}";
}
}
});
@@ -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() {
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)
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() {
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index 4801ec77eb..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) {
@@ -366,6 +381,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 +579,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.field_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 +617,7 @@ function submitFormData(fields, options) {
} else {
console.log(`WARNING: Could not find field matching '${name}'`);
}
- });
+ }
var upload_func = inventreePut;
@@ -1279,6 +1303,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 +1321,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 +1388,10 @@ function constructField(name, parameters, options) {
html += `
`; // controls
html += `
`; // form-group
+ if (parameters.after) {
+ html += parameters.after;
+ }
+
return html;
}
@@ -1430,6 +1470,9 @@ function constructInput(name, parameters, options) {
case 'date':
func = constructDateInput;
break;
+ case 'candy':
+ func = constructCandyInput;
+ break;
default:
// Unsupported field type!
break;
@@ -1658,6 +1701,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
*
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 169c722d79..3def7abdad 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -13,91 +13,213 @@ 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,
+ },
+ };
+
+ // 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"];
+ }
+
+ // 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_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" %}',
+ 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 {
+ parent: {
+ help_text: '{% trans "Parent part category" %}',
+ },
+ name: {},
+ description: {},
+ default_location: {},
+ default_keywords: {
+ icon: 'fa-key',
+ }
+ };
+}
+
+
+// Edit a PartCategory via the API
+function editCategory(pk, options={}) {
+
+ var url = `/api/part/category/${pk}/`;
+
+ var fields = categoryFields();
+
+ constructForm(url, {
+ fields: fields,
+ title: '{% trans "Edit Part Category" %}',
+ reload: true,
+ });
+
+}
+
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,
title: '{% trans "Edit Part" %}',
reload: true,
});
+}
+
+// Launch form to duplicate a part
+function duplicatePart(pk, options={}) {
+
+ // First we need all the part information
+ inventreeGet(`/api/part/${pk}/`, {}, {
+
+ 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: data,
+ onSuccess: function(data) {
+ // Follow the new part
+ location.href = `/part/${data.pk}/`;
+ }
+ });
+ }
+ });
}
diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js
index afe1fefbc9..88d9a5f99a 100644
--- a/InvenTree/templates/js/translated/tables.js
+++ b/InvenTree/templates/js/translated/tables.js
@@ -187,7 +187,7 @@ $.fn.inventreeTable = function(options) {
if (!options.disablePagination) {
options.pagination = true;
options.paginationVAlign = options.paginationVAlign || 'both';
- options.pageSize = inventreeLoad(varName, 25);
+ options.pageSize = options.pageSize || inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all'];
options.totalField = 'count';
options.dataField = 'results';
diff --git a/requirements.txt b/requirements.txt
index abcf2cb098..839237c6a1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,7 +21,8 @@ coverage==5.3 # Unit test coverage
coveralls==2.1.2 # Coveralls linking (for Travis)
rapidfuzz==0.7.6 # Fuzzy string matching
django-stdimage==5.1.1 # Advanced ImageField management
-django-weasyprint==1.0.1 # HTML PDF export
+weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53)
+django-weasyprint==1.0.1 # django weasyprint integration
django-debug-toolbar==2.2 # Debug / profiling toolbar
django-admin-shell==0.1.2 # Python shell for the admin interface
py-moneyed==0.8.0 # Specific version requirement for py-moneyed