@@ -54,4 +70,9 @@
{% block js_ready %}
{{ block.super }}
+enableNavbar({
+ label: 'part',
+ toggleId: '#part-menu-toggle',
+});
+
{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py
index b12a59f136..3b88deb504 100644
--- a/InvenTree/part/templatetags/inventree_extras.py
+++ b/InvenTree/part/templatetags/inventree_extras.py
@@ -6,6 +6,7 @@ over and above the built-in Django tags.
import os
import sys
+from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from django.conf import settings as djangosettings
@@ -262,6 +263,26 @@ def get_available_themes(*args, **kwargs):
return themes
+@register.simple_tag()
+def primitive_to_javascript(primitive):
+ """
+ Convert a python primitive to a javascript primitive.
+
+ e.g. True -> true
+ 'hello' -> '"hello"'
+ """
+
+ if type(primitive) is bool:
+ return str(primitive).lower()
+
+ elif type(primitive) in [int, float]:
+ return primitive
+
+ else:
+ # Wrap with quotes
+ return format_html("'{}'", primitive)
+
+
@register.filter
def keyvalue(dict, key):
"""
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index bbd73b73e0..ebef21b84b 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
'location',
'bom',
'test_templates',
+ 'company',
]
roles = [
@@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertFalse(response.data['active'])
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):
"""
diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py
index 13ec3a179e..f8ed5ee305 100644
--- a/InvenTree/part/test_bom_export.py
+++ b/InvenTree/part/test_bom_export.py
@@ -2,6 +2,8 @@
Unit testing for BOM export functionality
"""
+import csv
+
from django.test import TestCase
from django.urls import reverse
@@ -47,13 +49,63 @@ class BomExportTest(TestCase):
self.url = reverse('bom-download', kwargs={'pk': 100})
+ def test_bom_template(self):
+ """
+ Test that the BOM template can be downloaded from the server
+ """
+
+ url = reverse('bom-upload-template')
+
+ # Download an XLS template
+ response = self.client.get(url, data={'format': 'xls'})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.headers['Content-Disposition'],
+ 'attachment; filename="InvenTree_BOM_Template.xls"'
+ )
+
+ # Return a simple CSV template
+ response = self.client.get(url, data={'format': 'csv'})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.headers['Content-Disposition'],
+ 'attachment; filename="InvenTree_BOM_Template.csv"'
+ )
+
+ filename = '_tmp.csv'
+
+ with open(filename, 'wb') as f:
+ f.write(response.getvalue())
+
+ with open(filename, 'r') as f:
+ reader = csv.reader(f, delimiter=',')
+
+ for line in reader:
+ headers = line
+ break
+
+ expected = [
+ 'part_id',
+ 'part_ipn',
+ 'part_name',
+ 'quantity',
+ 'optional',
+ 'overage',
+ 'reference',
+ 'note',
+ 'inherited',
+ 'allow_variants',
+ ]
+
+ # Ensure all the expected headers are in the provided file
+ for header in expected:
+ self.assertTrue(header in headers)
+
def test_export_csv(self):
"""
Test BOM download in CSV format
"""
- print("URL", self.url)
-
params = {
'file_format': 'csv',
'cascade': True,
@@ -70,6 +122,47 @@ class BomExportTest(TestCase):
content = response.headers['Content-Disposition']
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"')
+ filename = '_tmp.csv'
+
+ with open(filename, 'wb') as f:
+ f.write(response.getvalue())
+
+ # Read the file
+ with open(filename, 'r') as f:
+ reader = csv.reader(f, delimiter=',')
+
+ for line in reader:
+ headers = line
+ break
+
+ expected = [
+ 'level',
+ 'bom_id',
+ 'parent_part_id',
+ 'parent_part_ipn',
+ 'parent_part_name',
+ 'part_id',
+ 'part_ipn',
+ 'part_name',
+ 'part_description',
+ 'sub_assembly',
+ 'quantity',
+ 'optional',
+ 'overage',
+ 'reference',
+ 'note',
+ 'inherited',
+ 'allow_variants',
+ 'Default Location',
+ 'Available Stock',
+ ]
+
+ for header in expected:
+ self.assertTrue(header in headers)
+
+ for header in headers:
+ self.assertTrue(header in expected)
+
def test_export_xls(self):
"""
Test BOM download in XLS format
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 41dc959f02..e7ec2fd291 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -25,7 +25,7 @@ import common.models
from company.serializers import SupplierPartSerializer
from part.serializers import PartBriefSerializer
from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
-from InvenTree.serializers import InvenTreeAttachmentSerializerField
+from InvenTree.serializers import InvenTreeAttachmentSerializer, InvenTreeAttachmentSerializerField
class LocationBriefSerializer(InvenTreeModelSerializer):
@@ -253,7 +253,7 @@ class LocationSerializer(InvenTreeModelSerializer):
]
-class StockItemAttachmentSerializer(InvenTreeModelSerializer):
+class StockItemAttachmentSerializer(InvenTreeAttachmentSerializer):
""" Serializer for StockItemAttachment model """
def __init__(self, *args, **kwargs):
@@ -277,6 +277,7 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'stock_item',
'attachment',
+ 'filename',
'comment',
'upload_date',
'user',
diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html
index 19295d1198..0ac9c285a6 100644
--- a/InvenTree/stock/templates/stock/item.html
+++ b/InvenTree/stock/templates/stock/item.html
@@ -215,6 +215,7 @@
constructForm(url, {
fields: {
+ filename: {},
comment: {},
},
title: '{% trans "Edit Attachment" %}',
diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js
index ad4e297c4a..60172ead64 100644
--- a/InvenTree/templates/js/dynamic/settings.js
+++ b/InvenTree/templates/js/dynamic/settings.js
@@ -2,16 +2,17 @@
// InvenTree settings
{% user_settings request.user as USER_SETTINGS %}
-{% global_settings as GLOBAL_SETTINGS %}
var user_settings = {
- {% for setting in USER_SETTINGS %}
- {{ setting.key }}: {{ setting.value }},
+ {% for key, value in USER_SETTINGS.items %}
+ {{ key }}: {% primitive_to_javascript value %},
{% endfor %}
};
+{% global_settings as GLOBAL_SETTINGS %}
+
var global_settings = {
- {% for setting in GLOBAL_SETTINGS %}
- {{ setting.key }}: {{ setting.value }},
+ {% for key, value in GLOBAL_SETTINGS.items %}
+ {{ key }}: {% primitive_to_javascript value %},
{% endfor %}
};
\ No newline at end of file
diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js
index 4b9d522a59..bffe3d9995 100644
--- a/InvenTree/templates/js/translated/attachment.js
+++ b/InvenTree/templates/js/translated/attachment.js
@@ -42,9 +42,32 @@ function loadAttachmentTable(url, options) {
title: '{% trans "File" %}',
formatter: function(value, row) {
- var split = value.split('/');
+ var icon = 'fa-file-alt';
- return renderLink(split[split.length - 1], value);
+ var fn = value.toLowerCase();
+
+ if (fn.endsWith('.pdf')) {
+ icon = 'fa-file-pdf';
+ } else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) {
+ icon = 'fa-file-excel';
+ } else if (fn.endsWith('.doc') || fn.endsWith('.docx')) {
+ icon = 'fa-file-word';
+ } else {
+ var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif'];
+
+ images.forEach(function (suffix) {
+ if (fn.endsWith(suffix)) {
+ icon = 'fa-file-image';
+ }
+ });
+ }
+
+ var split = value.split('/');
+ var filename = split[split.length - 1];
+
+ var html = ` ${filename}`;
+
+ return renderLink(html, value);
}
},
{
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index 34a6206ac9..37a3eb23b0 100644
--- a/InvenTree/templates/js/translated/bom.js
+++ b/InvenTree/templates/js/translated/bom.js
@@ -252,7 +252,7 @@ function loadBomTable(table, options) {
sortable: true,
formatter: function(value, row, index, field) {
- var url = `/part/${row.sub_part_detail.pk}/stock/`;
+ var url = `/part/${row.sub_part_detail.pk}/?display=stock`;
var text = value;
if (value == null || value <= 0) {
diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js
index 4b41623fbf..904053a423 100644
--- a/InvenTree/templates/js/translated/forms.js
+++ b/InvenTree/templates/js/translated/forms.js
@@ -264,6 +264,10 @@ function constructForm(url, options) {
// Default HTTP method
options.method = options.method || 'PATCH';
+ // Default "groups" definition
+ options.groups = options.groups || {};
+ options.current_group = null;
+
// Construct an "empty" data object if not provided
if (!options.data) {
options.data = {};
@@ -362,6 +366,13 @@ function constructFormBody(fields, options) {
}
}
+ // Initialize an "empty" field for each specified field
+ for (field in displayed_fields) {
+ if (!(field in fields)) {
+ fields[field] = {};
+ }
+ }
+
// Provide each field object with its own name
for(field in fields) {
fields[field].name = field;
@@ -379,52 +390,18 @@ function constructFormBody(fields, options) {
// Override existing query filters (if provided!)
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
- fields[field].before = field_options.before;
- fields[field].after = field_options.after;
+ var val = field_options[opt];
- // Secondary modal options
- fields[field].secondary = field_options.secondary;
-
- // Edit callback
- fields[field].onEdit = field_options.onEdit;
-
- fields[field].multiline = field_options.multiline;
-
- // 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 = ``;
- }
-
- fields[field].hidden = field_options.hidden;
-
- if (field_options.read_only != null) {
- fields[field].read_only = field_options.read_only;
+ if (opt == 'filters') {
+ // ignore filters (see above)
+ } else if (opt == 'icon') {
+ // Specify custom icon
+ fields[field].prefix = ``;
+ } else {
+ fields[field][opt] = field_options[opt];
+ }
}
}
}
@@ -465,8 +442,10 @@ function constructFormBody(fields, options) {
html += constructField(name, field, options);
}
- // TODO: Dynamically create the modals,
- // so that we can have an infinite number of stacks!
+ if (options.current_group) {
+ // Close out the current group
+ html += `
`;
+ }
// Create a new modal if one does not exists
if (!options.modal) {
@@ -535,6 +514,11 @@ function constructFormBody(fields, options) {
submitFormData(fields, options);
}
});
+
+ initializeGroups(fields, options);
+
+ // Scroll to the top
+ $(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
}
@@ -860,9 +844,12 @@ function handleFormErrors(errors, fields, options) {
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(
`
{% trans "Form errors exist" %}
+
+
`
);
@@ -883,6 +870,8 @@ function handleFormErrors(errors, fields, options) {
}
}
+ var first_error_field = null;
+
for (field_name in errors) {
// Add the 'has-error' class
@@ -892,6 +881,10 @@ function handleFormErrors(errors, fields, options) {
var field_errors = errors[field_name];
+ if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
+ first_error_field = field_name;
+ }
+
// Add an entry for each returned error message
for (var idx = field_errors.length-1; idx >= 0; idx--) {
@@ -905,6 +898,24 @@ function handleFormErrors(errors, fields, options) {
field_dom.append(html);
}
}
+
+ if (first_error_field) {
+ // Ensure that the field in question is visible
+ document.querySelector(`#div_id_${field_name}`).scrollIntoView({
+ behavior: 'smooth',
+ });
+ } else {
+ // Scroll to the top of the form
+ $(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
+ }
+
+ $(options.modal).find('.modal-content').addClass('modal-error');
+}
+
+
+function isFieldVisible(field, options) {
+
+ return $(options.modal).find(`#div_id_${field}`).is(':visible');
}
@@ -932,7 +943,10 @@ function addFieldCallbacks(fields, options) {
function addFieldCallback(name, field, options) {
$(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 +974,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) {
var field_names = options.field_names;
@@ -1353,6 +1432,8 @@ function renderModelData(name, model, data, parameters, options) {
*/
function constructField(name, parameters, options) {
+ var html = '';
+
// Shortcut for simple visual fields
if (parameters.type == 'candy') {
return constructCandyInput(name, parameters, options);
@@ -1365,13 +1446,58 @@ function constructField(name, parameters, options) {
return constructHiddenInput(name, parameters, options);
}
+ // Are we ending a group?
+ if (options.current_group && parameters.group != options.current_group) {
+ html += `
`;
+
+ // 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 += `
+
+
`;
+ if (group_options.collapsible) {
+ html += `
+
+ `;
+ }
+
+ // Keep track of the group we are in
+ options.current_group = group;
+ }
+
var form_classes = 'form-group';
if (parameters.errors) {
form_classes += ' has-error';
}
-
- var html = '';
// Optional content to render before the field
if (parameters.before) {
@@ -1428,13 +1554,14 @@ function constructField(name, parameters, options) {
html += `
`; // input-group
}
- // Div for error messages
- html += ``;
-
if (parameters.help_text) {
html += constructHelpText(name, parameters, options);
}
+ // Div for error messages
+ html += ``;
+
+
html += `
`; // controls
html += `
`; // form-group
@@ -1599,6 +1726,10 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`placeholder='${parameters.placeholder}'`);
}
+ if (parameters.type == 'boolean') {
+ opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`);
+ }
+
if (parameters.multiline) {
return ``;
} else {
@@ -1772,7 +1903,13 @@ function constructCandyInput(name, parameters, options) {
*/
function constructHelpText(name, parameters, options) {
- var html = `
${parameters.help_text}
`;
+ var style = '';
+
+ if (parameters.type == 'boolean') {
+ style = `style='display: inline-block; margin-left: 25px' `;
+ }
+
+ var html = `
${parameters.help_text}
`;
return html;
}
\ No newline at end of file
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 3d8a21c66a..4ed631fe61 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -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
function partFields(options={}) {
@@ -48,36 +73,44 @@ function partFields(options={}) {
minimum_stock: {
icon: 'fa-boxes',
},
- attributes: {
- type: 'candy',
- html: `
{% trans "Part Attributes" %}
`
- },
component: {
value: global_settings.PART_COMPONENT,
+ group: 'attributes',
},
assembly: {
value: global_settings.PART_ASSEMBLY,
+ group: 'attributes',
},
is_template: {
value: global_settings.PART_TEMPLATE,
+ group: 'attributes',
},
trackable: {
value: global_settings.PART_TRACKABLE,
+ group: 'attributes',
},
purchaseable: {
value: global_settings.PART_PURCHASEABLE,
+ group: 'attributes',
+ onEdit: function(value, name, field, options) {
+ setFormGroupVisibility('supplier', value, options);
+ }
},
salable: {
value: global_settings.PART_SALABLE,
+ group: 'attributes',
},
virtual: {
value: global_settings.PART_VIRTUAL,
+ group: 'attributes',
},
};
// If editing a part, we can set the "active" status
if (options.edit) {
- fields.active = {};
+ fields.active = {
+ group: 'attributes'
+ };
}
// Pop expiry field
@@ -91,16 +124,32 @@ function partFields(options={}) {
// 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: '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',
+ value: 1,
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" %}',
help_text: '{% trans "Copy parameter templates from selected part category" %}',
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
if (options.duplicate) {
- fields.duplicate = {
- type: 'candy',
- html: `
{% trans "Part Duplication Options" %}
`,
- };
-
fields.copy_from = {
type: 'integer',
hidden: true,
value: options.duplicate,
+ group: 'duplicate',
},
fields.copy_image = {
@@ -131,6 +224,7 @@ function partFields(options={}) {
label: '{% trans "Copy Image" %}',
help_text: '{% trans "Copy image from original part" %}',
value: true,
+ group: 'duplicate',
},
fields.copy_bom = {
@@ -138,6 +232,7 @@ function partFields(options={}) {
label: '{% trans "Copy BOM" %}',
help_text: '{% trans "Copy bill of materials from original part" %}',
value: global_settings.PART_COPY_BOM,
+ group: 'duplicate',
};
fields.copy_parameters = {
@@ -145,6 +240,7 @@ function partFields(options={}) {
label: '{% trans "Copy Parameters" %}',
help_text: '{% trans "Copy parameter data from original part" %}',
value: global_settings.PART_COPY_PARAMETERS,
+ group: 'duplicate',
};
}
@@ -191,8 +287,11 @@ function editPart(pk, options={}) {
edit: true
});
+ var groups = partGroups({});
+
constructForm(url, {
fields: fields,
+ groups: partGroups(),
title: '{% trans "Edit Part" %}',
reload: true,
});
@@ -221,6 +320,7 @@ function duplicatePart(pk, options={}) {
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
+ groups: partGroups(),
title: '{% trans "Duplicate Part" %}',
data: data,
onSuccess: function(data) {
@@ -400,7 +500,7 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock',
title: '{% trans "Stock" %}',
formatter: function(value, row) {
- return renderLink(value, `/part/${row.pk}/stock/`);
+ return renderLink(value, `/part/${row.pk}/?display=stock`);
}
}
];
@@ -903,6 +1003,18 @@ function loadPartTable(table, url, options={}) {
});
});
+ $('#multi-part-print-label').click(function() {
+ var selections = $(table).bootstrapTable('getSelections');
+
+ var items = [];
+
+ selections.forEach(function(item) {
+ items.push(item.pk);
+ });
+
+ printPartLabels(items);
+ });
+
$('#multi-part-export').click(function() {
var selections = $(table).bootstrapTable("getSelections");
diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index f489b45948..d722f3bff8 100644
--- a/InvenTree/templates/js/translated/stock.js
+++ b/InvenTree/templates/js/translated/stock.js
@@ -1066,7 +1066,7 @@ function loadStockTable(table, options) {
return '-';
}
- var link = `/supplier-part/${row.supplier_part}/stock/`;
+ var link = `/supplier-part/${row.supplier_part}/?display=stock`;
var text = '';
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 8f57d8a18c..e4ebbc1b4b 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -35,51 +35,48 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
ENV INVENTREE_GUNICORN_WORKERS="4"
ENV INVENTREE_BACKGROUND_WORKERS="4"
-# Default web server port is 8000
-ENV INVENTREE_WEB_PORT="8000"
+# Default web server address:port
+ENV INVENTREE_WEB_ADDR=0.0.0.0
+ENV INVENTREE_WEB_PORT=8000
LABEL org.label-schema.schema-version="1.0" \
org.label-schema.build-date=${DATE} \
org.label-schema.vendor="inventree" \
org.label-schema.name="inventree/inventree" \
org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \
- org.label-schema.version=${INVENTREE_VERSION} \
- org.label-schema.vcs-url=${INVENTREE_REPO} \
- org.label-schema.vcs-branch=${BRANCH} \
- org.label-schema.vcs-ref=${COMMIT}
+ org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \
+ org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \
+ org.label-schema.vcs-ref=${INVENTREE_GIT_TAG}
# Create user account
RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup
-WORKDIR ${INVENTREE_HOME}
-
# Install required system packages
RUN apk add --no-cache git make bash \
gcc libgcc g++ libstdc++ \
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
libffi libffi-dev \
- zlib zlib-dev
+ zlib zlib-dev \
+ # Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
+ cairo cairo-dev pango pango-dev \
+ # Fonts
+ fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
+ # Core python
+ python3 python3-dev py3-pip \
+ # SQLite support
+ sqlite \
+ # PostgreSQL support
+ postgresql postgresql-contrib postgresql-dev libpq \
+ # MySQL/MariaDB support
+ mariadb-connector-c mariadb-dev mariadb-client
-# Cairo deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
-RUN apk add --no-cache cairo cairo-dev pango pango-dev
-RUN apk add --no-cache fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto
-
-# Python
-RUN apk add --no-cache python3 python3-dev py3-pip
-
-# SQLite support
-RUN apk add --no-cache sqlite
-
-# PostgreSQL support
-RUN apk add --no-cache postgresql postgresql-contrib postgresql-dev libpq
-
-# MySQL support
-RUN apk add --no-cache mariadb-connector-c mariadb-dev mariadb-client
-
-# Install required python packages
-RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb
+# Install required base-level python packages
+COPY requirements.txt requirements.txt
+RUN pip install --no-cache-dir -U -r requirements.txt
+# Production code (pulled from tagged github release)
FROM base as production
+
# Clone source code
RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}"
@@ -88,30 +85,35 @@ RUN git clone --branch ${INVENTREE_GIT_BRANCH} --depth 1 ${INVENTREE_GIT_REPO} $
# Checkout against a particular git tag
RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch --all --tags && git checkout tags/${INVENTREE_GIT_TAG} -b v${INVENTREE_GIT_TAG}-branch ; fi
+RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/*
+
+# Drop to the inventree user
+USER inventree
+
# Install InvenTree packages
-RUN pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt
+RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt
-# Copy gunicorn config file
-COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py
+# Need to be running from within this directory
+WORKDIR ${INVENTREE_MNG_DIR}
-# Copy startup scripts
-COPY start_prod_server.sh ${INVENTREE_HOME}/start_prod_server.sh
-COPY start_prod_worker.sh ${INVENTREE_HOME}/start_prod_worker.sh
+# Server init entrypoint
+ENTRYPOINT ["/bin/bash", "../docker/init.sh"]
-RUN chmod 755 ${INVENTREE_HOME}/start_prod_server.sh
-RUN chmod 755 ${INVENTREE_HOME}/start_prod_worker.sh
-
-WORKDIR ${INVENTREE_HOME}
-
-# Let us begin
-CMD ["bash", "./start_prod_server.sh"]
+# Launch the production server
+# TODO: Work out why environment variables cannot be interpolated in this command
+# TODO: e.g. -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} fails here
+CMD gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree
FROM base as dev
# The development image requires the source code to be mounted to /home/inventree/
# So from here, we don't actually "do" anything, apart from some file management
-ENV INVENTREE_DEV_DIR = "${INVENTREE_HOME}/dev"
+ENV INVENTREE_DEV_DIR="${INVENTREE_HOME}/dev"
+
+# Location for python virtual environment
+# If the INVENTREE_PY_ENV variable is set, the entrypoint script will use it!
+ENV INVENTREE_PY_ENV="${INVENTREE_DEV_DIR}/env"
# Override default path settings
ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static"
@@ -121,5 +123,9 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt"
WORKDIR ${INVENTREE_HOME}
+# Entrypoint ensures that we are running in the python virtual environment
+ENTRYPOINT ["/bin/bash", "./docker/init.sh"]
+
# Launch the development server
-CMD ["bash", "/home/inventree/docker/start_dev_server.sh"]
+CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"]
+
diff --git a/docker/dev-config.env b/docker/dev-config.env
index fe1f073633..927f505649 100644
--- a/docker/dev-config.env
+++ b/docker/dev-config.env
@@ -1,9 +1,15 @@
+# InvenTree environment variables for a development setup
+
+# Set DEBUG to False for a production environment!
+INVENTREE_DEBUG=True
+
+# Change verbosity level for debug output
+INVENTREE_DEBUG_LEVEL="INFO"
+
+# Database linking options
INVENTREE_DB_ENGINE=sqlite3
INVENTREE_DB_NAME=/home/inventree/dev/inventree_db.sqlite3
-INVENTREE_MEDIA_ROOT=/home/inventree/dev/media
-INVENTREE_STATIC_ROOT=/home/inventree/dev/static
-INVENTREE_CONFIG_FILE=/home/inventree/dev/config.yaml
-INVENTREE_SECRET_KEY_FILE=/home/inventree/dev/secret_key.txt
-INVENTREE_DEBUG=true
-INVENTREE_WEB_ADDR=0.0.0.0
-INVENTREE_WEB_PORT=8000
\ No newline at end of file
+# INVENTREE_DB_HOST=hostaddress
+# INVENTREE_DB_PORT=5432
+# INVENTREE_DB_USERNAME=dbuser
+# INVENTREE_DB_PASSWEORD=dbpassword
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index 29eccc26c6..c4be092189 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -19,6 +19,7 @@ services:
context: .
target: dev
ports:
+ # Expose web server on port 8000
- 8000:8000
volumes:
# Ensure you specify the location of the 'src' directory at the end of this file
@@ -26,7 +27,6 @@ services:
env_file:
# Environment variables required for the dev server are configured in dev-config.env
- dev-config.env
-
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
@@ -35,7 +35,7 @@ services:
build:
context: .
target: dev
- entrypoint: /home/inventree/docker/start_dev_worker.sh
+ command: invoke worker
depends_on:
- inventree-dev-server
volumes:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index dcd35af148..3f8443065a 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -21,12 +21,13 @@ services:
# just make sure that you change the INVENTREE_DB_xxx vars below
inventree-db:
container_name: inventree-db
- image: postgres
+ image: postgres:13
ports:
- 5432/tcp
environment:
- PGDATA=/var/lib/postgresql/data/pgdb
# The pguser and pgpassword values must be the same in the other containers
+ # Ensure that these are correctly configured in your prod-config.env file
- POSTGRES_USER=pguser
- POSTGRES_PASSWORD=pgpassword
volumes:
@@ -38,6 +39,8 @@ services:
# Uses gunicorn as the web server
inventree-server:
container_name: inventree-server
+ # If you wish to specify a particular InvenTree version, do so here
+ # e.g. image: inventree/inventree:0.5.2
image: inventree/inventree:latest
expose:
- 8000
@@ -46,39 +49,27 @@ services:
volumes:
# Data volume must map to /home/inventree/data
- data:/home/inventree/data
- environment:
- # Default environment variables are configured to match the 'db' container
- # Note: If you change the database image, these will need to be adjusted
- # Note: INVENTREE_DB_HOST should match the container name of the database
- - INVENTREE_DB_USER=pguser
- - INVENTREE_DB_PASSWORD=pgpassword
- - INVENTREE_DB_ENGINE=postgresql
- - INVENTREE_DB_NAME=inventree
- - INVENTREE_DB_HOST=inventree-db
- - INVENTREE_DB_PORT=5432
+ env_file:
+ # Environment variables required for the production server are configured in prod-config.env
+ - prod-config.env
restart: unless-stopped
# Background worker process handles long-running or periodic tasks
inventree-worker:
container_name: inventree-worker
+ # If you wish to specify a particular InvenTree version, do so here
+ # e.g. image: inventree/inventree:0.5.2
image: inventree/inventree:latest
- entrypoint: ./start_prod_worker.sh
+ command: invoke worker
depends_on:
- inventree-db
- inventree-server
volumes:
# Data volume must map to /home/inventree/data
- data:/home/inventree/data
- environment:
- # Default environment variables are configured to match the 'db' container
- # Note: If you change the database image, these will need to be adjusted
- # Note: INVENTREE_DB_HOST should match the container name of the database
- - INVENTREE_DB_USER=pguser
- - INVENTREE_DB_PASSWORD=pgpassword
- - INVENTREE_DB_ENGINE=postgresql
- - INVENTREE_DB_NAME=inventree
- - INVENTREE_DB_HOST=inventree-db
- - INVENTREE_DB_PORT=5432
+ env_file:
+ # Environment variables required for the production server are configured in prod-config.env
+ - prod-config.env
restart: unless-stopped
# nginx acts as a reverse proxy
@@ -88,7 +79,7 @@ services:
# NOTE: You will need to provide a working nginx.conf file!
inventree-proxy:
container_name: inventree-proxy
- image: nginx
+ image: nginx:stable
depends_on:
- inventree-server
ports:
diff --git a/docker/init.sh b/docker/init.sh
new file mode 100644
index 0000000000..b598a3ee79
--- /dev/null
+++ b/docker/init.sh
@@ -0,0 +1,42 @@
+#!/bin/sh
+# exit when any command fails
+set -e
+
+# Create required directory structure (if it does not already exist)
+if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
+ echo "Creating directory $INVENTREE_STATIC_ROOT"
+ mkdir -p $INVENTREE_STATIC_ROOT
+fi
+
+if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
+ echo "Creating directory $INVENTREE_MEDIA_ROOT"
+ mkdir -p $INVENTREE_MEDIA_ROOT
+fi
+
+# Check if "config.yaml" has been copied into the correct location
+if test -f "$INVENTREE_CONFIG_FILE"; then
+ echo "$INVENTREE_CONFIG_FILE exists - skipping"
+else
+ echo "Copying config file to $INVENTREE_CONFIG_FILE"
+ cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
+fi
+
+# Setup a python virtual environment
+# This should be done on the *mounted* filesystem,
+# so that the installed modules persist!
+if [[ -n "$INVENTREE_PY_ENV" ]]; then
+ echo "Using Python virtual environment: ${INVENTREE_PY_ENV}"
+ # Setup a virtual environment (within the "dev" directory)
+ python3 -m venv ${INVENTREE_PY_ENV}
+
+ # Activate the virtual environment
+ source ${INVENTREE_PY_ENV}/bin/activate
+
+ # Note: Python packages will have to be installed on first run
+ # e.g docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke install
+fi
+
+cd ${INVENTREE_HOME}
+
+# Launch the CMD *after* the ENTRYPOINT completes
+exec "$@"
diff --git a/docker/prod-config.env b/docker/prod-config.env
new file mode 100644
index 0000000000..50cf7a867b
--- /dev/null
+++ b/docker/prod-config.env
@@ -0,0 +1,16 @@
+# InvenTree environment variables for a production setup
+
+# Note: If your production setup varies from the example, you may want to change these values
+
+# Ensure debug is false for a production setup
+INVENTREE_DEBUG=False
+INVENTREE_LOG_LEVEL="WARNING"
+
+# Database configuration
+# Note: The example setup is for a PostgreSQL database (change as required)
+INVENTREE_DB_ENGINE=postgresql
+INVENTREE_DB_NAME=inventree
+INVENTREE_DB_HOST=inventree-db
+INVENTREE_DB_PORT=5432
+INVENTREE_DB_USER=pguser
+INVENTREE_DB_PASSWORD=pgpassword
diff --git a/docker/requirements.txt b/docker/requirements.txt
new file mode 100644
index 0000000000..b15d7c538d
--- /dev/null
+++ b/docker/requirements.txt
@@ -0,0 +1,13 @@
+# Base python requirements for docker containers
+
+# Basic package requirements
+setuptools>=57.4.0
+wheel>=0.37.0
+invoke>=1.4.0 # Invoke build tool
+gunicorn>=20.1.0 # Gunicorn web server
+
+# Database links
+psycopg2>=2.9.1
+mysqlclient>=2.0.3
+pgcli>=3.1.0
+mariadb>=1.0.7
diff --git a/docker/start_dev_server.sh b/docker/start_dev_server.sh
deleted file mode 100644
index a12a958a9a..0000000000
--- a/docker/start_dev_server.sh
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/sh
-
-# Create required directory structure (if it does not already exist)
-if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
- echo "Creating directory $INVENTREE_STATIC_ROOT"
- mkdir -p $INVENTREE_STATIC_ROOT
-fi
-
-if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
- echo "Creating directory $INVENTREE_MEDIA_ROOT"
- mkdir -p $INVENTREE_MEDIA_ROOT
-fi
-
-# Check if "config.yaml" has been copied into the correct location
-if test -f "$INVENTREE_CONFIG_FILE"; then
- echo "$INVENTREE_CONFIG_FILE exists - skipping"
-else
- echo "Copying config file to $INVENTREE_CONFIG_FILE"
- cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
-fi
-
-# Setup a virtual environment (within the "dev" directory)
-python3 -m venv ./dev/env
-
-# Activate the virtual environment
-source ./dev/env/bin/activate
-
-echo "Installing required packages..."
-pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt
-
-echo "Starting InvenTree server..."
-
-# Wait for the database to be ready
-cd ${INVENTREE_HOME}/InvenTree
-python3 manage.py wait_for_db
-
-sleep 10
-
-echo "Running InvenTree database migrations..."
-
-# We assume at this stage that the database is up and running
-# Ensure that the database schema are up to date
-python3 manage.py check || exit 1
-python3 manage.py migrate --noinput || exit 1
-python3 manage.py migrate --run-syncdb || exit 1
-python3 manage.py clearsessions || exit 1
-
-invoke static
-
-# Launch a development server
-python3 manage.py runserver ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}
diff --git a/docker/start_dev_worker.sh b/docker/start_dev_worker.sh
deleted file mode 100644
index 7ee59ff28f..0000000000
--- a/docker/start_dev_worker.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/sh
-
-echo "Starting InvenTree worker..."
-
-cd $INVENTREE_HOME
-
-# Activate virtual environment
-source ./dev/env/bin/activate
-
-sleep 5
-
-# Wait for the database to be ready
-cd InvenTree
-python3 manage.py wait_for_db
-
-sleep 10
-
-# Now we can launch the background worker process
-python3 manage.py qcluster
diff --git a/docker/start_prod_server.sh b/docker/start_prod_server.sh
deleted file mode 100644
index 1660a64e60..0000000000
--- a/docker/start_prod_server.sh
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/bin/sh
-
-# Create required directory structure (if it does not already exist)
-if [[ ! -d "$INVENTREE_STATIC_ROOT" ]]; then
- echo "Creating directory $INVENTREE_STATIC_ROOT"
- mkdir -p $INVENTREE_STATIC_ROOT
-fi
-
-if [[ ! -d "$INVENTREE_MEDIA_ROOT" ]]; then
- echo "Creating directory $INVENTREE_MEDIA_ROOT"
- mkdir -p $INVENTREE_MEDIA_ROOT
-fi
-
-# Check if "config.yaml" has been copied into the correct location
-if test -f "$INVENTREE_CONFIG_FILE"; then
- echo "$INVENTREE_CONFIG_FILE exists - skipping"
-else
- echo "Copying config file to $INVENTREE_CONFIG_FILE"
- cp $INVENTREE_HOME/InvenTree/config_template.yaml $INVENTREE_CONFIG_FILE
-fi
-
-echo "Starting InvenTree server..."
-
-# Wait for the database to be ready
-cd $INVENTREE_MNG_DIR
-python3 manage.py wait_for_db
-
-sleep 10
-
-echo "Running InvenTree database migrations and collecting static files..."
-
-# We assume at this stage that the database is up and running
-# Ensure that the database schema are up to date
-python3 manage.py check || exit 1
-python3 manage.py migrate --noinput || exit 1
-python3 manage.py migrate --run-syncdb || exit 1
-python3 manage.py prerender || exit 1
-python3 manage.py collectstatic --noinput || exit 1
-python3 manage.py clearsessions || exit 1
-
-# Now we can launch the server
-gunicorn -c $INVENTREE_HOME/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:$INVENTREE_WEB_PORT
diff --git a/docker/start_prod_worker.sh b/docker/start_prod_worker.sh
deleted file mode 100644
index d0762b430e..0000000000
--- a/docker/start_prod_worker.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/sh
-
-echo "Starting InvenTree worker..."
-
-sleep 5
-
-# Wait for the database to be ready
-cd $INVENTREE_MNG_DIR
-python3 manage.py wait_for_db
-
-sleep 10
-
-# Now we can launch the background worker process
-python3 manage.py qcluster
diff --git a/requirements.txt b/requirements.txt
index 637dbda99a..049bedcbeb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,5 @@
-# Basic package requirements
-setuptools>=57.4.0
-wheel>=0.37.0
-invoke>=1.4.0 # Invoke build tool
-gunicorn>=20.1.0 # Gunicorn web server
-
# Django framework
-Django==3.2.4 # Django package
+Django==3.2.4 # Django package
pillow==8.2.0 # Image manipulation
djangorestframework==3.12.4 # DRF framework
diff --git a/tasks.py b/tasks.py
index a9168f4649..7ebdd17480 100644
--- a/tasks.py
+++ b/tasks.py
@@ -65,7 +65,7 @@ def manage(c, cmd, pty=False):
cmd - django command to run
"""
- c.run('cd "{path}" && python3 manage.py {cmd}'.format(
+ result = c.run('cd "{path}" && python3 manage.py {cmd}'.format(
path=managePyDir(),
cmd=cmd
), pty=pty)
@@ -80,14 +80,6 @@ def install(c):
# Install required Python packages with PIP
c.run('pip3 install -U -r requirements.txt')
- # If a config.yaml file does not exist, copy from the template!
- CONFIG_FILE = os.path.join(localDir(), 'InvenTree', 'config.yaml')
- CONFIG_TEMPLATE_FILE = os.path.join(localDir(), 'InvenTree', 'config_template.yaml')
-
- if not os.path.exists(CONFIG_FILE):
- print("Config file 'config.yaml' does not exist - copying from template.")
- copyfile(CONFIG_TEMPLATE_FILE, CONFIG_FILE)
-
@task
def shell(c):
@@ -97,13 +89,6 @@ def shell(c):
manage(c, 'shell', pty=True)
-@task
-def worker(c):
- """
- Run the InvenTree background worker process
- """
-
- manage(c, 'qcluster', pty=True)
@task
def superuser(c):
@@ -113,6 +98,7 @@ def superuser(c):
manage(c, 'createsuperuser', pty=True)
+
@task
def check(c):
"""
@@ -121,13 +107,24 @@ def check(c):
manage(c, "check")
+
@task
def wait(c):
"""
Wait until the database connection is ready
"""
- manage(c, "wait_for_db")
+ return manage(c, "wait_for_db")
+
+
+@task(pre=[wait])
+def worker(c):
+ """
+ Run the InvenTree background worker process
+ """
+
+ manage(c, 'qcluster', pty=True)
+
@task
def rebuild(c):
@@ -137,6 +134,7 @@ def rebuild(c):
manage(c, "rebuild_models")
+
@task
def clean_settings(c):
"""
@@ -145,7 +143,7 @@ def clean_settings(c):
manage(c, "clean_settings")
-@task
+@task(post=[rebuild])
def migrate(c):
"""
Performs database migrations.
@@ -156,7 +154,7 @@ def migrate(c):
print("========================================")
manage(c, "makemigrations")
- manage(c, "migrate")
+ manage(c, "migrate --noinput")
manage(c, "migrate --run-syncdb")
manage(c, "check")
@@ -175,22 +173,6 @@ def static(c):
manage(c, "collectstatic --no-input")
-@task(pre=[install, migrate, static, clean_settings])
-def update(c):
- """
- Update InvenTree installation.
-
- This command should be invoked after source code has been updated,
- e.g. downloading new code from GitHub.
-
- The following tasks are performed, in order:
-
- - install
- - migrate
- - static
- """
- pass
-
@task(post=[static])
def translate(c):
"""
@@ -206,7 +188,26 @@ def translate(c):
path = os.path.join('InvenTree', 'script', 'translation_stats.py')
- c.run(f'python {path}')
+ c.run(f'python3 {path}')
+
+
+@task(pre=[install, migrate, translate, clean_settings])
+def update(c):
+ """
+ Update InvenTree installation.
+
+ This command should be invoked after source code has been updated,
+ e.g. downloading new code from GitHub.
+
+ The following tasks are performed, in order:
+
+ - install
+ - migrate
+ - translate
+ - clean_settings
+ """
+ pass
+
@task
def style(c):
@@ -217,6 +218,7 @@ def style(c):
print("Running PEP style checks...")
c.run('flake8 InvenTree')
+
@task
def test(c, database=None):
"""
@@ -228,6 +230,7 @@ def test(c, database=None):
# Run coverage tests
manage(c, 'test', pty=True)
+
@task
def coverage(c):
"""