From 537573d0e3b6019d051fc03dee89033747e5f1fa Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 Aug 2021 23:40:07 +1000 Subject: [PATCH 01/59] Add extra unit testing for BOM export --- .gitignore | 1 + InvenTree/part/test_bom_export.py | 99 ++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 5610fc4304..f3fa0ac8c1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ local_settings.py # Files used for testing dummy_image.* +_tmp.csv # Sphinx files docs/_build diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index 13ec3a179e..a8f949ac5e 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -2,6 +2,10 @@ Unit testing for BOM export functionality """ +import csv +from os import read +from django.http import response + from django.test import TestCase from django.urls import reverse @@ -47,13 +51,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 +124,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 From 26ddd36666a7bdeabdb75e50fe6f97501475da3f Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 12 Aug 2021 23:47:42 +1000 Subject: [PATCH 02/59] PEP fixes --- InvenTree/part/test_bom_export.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index a8f949ac5e..f8ed5ee305 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -3,8 +3,6 @@ Unit testing for BOM export functionality """ import csv -from os import read -from django.http import response from django.test import TestCase From 9205d6d67cae3bfe09196f381c502af695b80797 Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 12 Aug 2021 14:27:00 -0400 Subject: [PATCH 03/59] Improved creation of purchase order line items from file upload --- InvenTree/InvenTree/static/css/inventree.css | 5 +++++ ...er_purchaseorderlineitem_unique_together.py | 18 ++++++++++++++++++ InvenTree/order/models.py | 2 +- .../order/order_wizard/match_parts.html | 2 +- .../templates/order/purchase_order_detail.html | 2 +- 5 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 InvenTree/order/migrations/0049_alter_purchaseorderlineitem_unique_together.py diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index adb5a41ee6..592bef396a 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -1037,6 +1037,11 @@ a.anchor { height: 30px; } +/* Force minimum width of number input fields to show at least ~5 digits */ +input[type='number']{ + min-width: 80px; +} + .search-menu { padding-top: 2rem; } diff --git a/InvenTree/order/migrations/0049_alter_purchaseorderlineitem_unique_together.py b/InvenTree/order/migrations/0049_alter_purchaseorderlineitem_unique_together.py new file mode 100644 index 0000000000..c451e1754d --- /dev/null +++ b/InvenTree/order/migrations/0049_alter_purchaseorderlineitem_unique_together.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2021-08-12 17:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0040_alter_company_currency'), + ('order', '0048_auto_20210702_2321'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='purchaseorderlineitem', + unique_together={('order', 'part', 'quantity', 'purchase_price')}, + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 248ecb277d..e55f5203ba 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -729,7 +729,7 @@ class PurchaseOrderLineItem(OrderLineItem): class Meta: unique_together = ( - ('order', 'part') + ('order', 'part', 'quantity', 'purchase_price') ) def __str__(self): diff --git a/InvenTree/order/templates/order/order_wizard/match_parts.html b/InvenTree/order/templates/order/order_wizard/match_parts.html index e0f030bad5..4f84d205ee 100644 --- a/InvenTree/order/templates/order/order_wizard/match_parts.html +++ b/InvenTree/order/templates/order/order_wizard/match_parts.html @@ -115,7 +115,7 @@ {{ block.super }} $('.bomselect').select2({ - dropdownAutoWidth: true, + width: '100%', matcher: partialMatcher, }); diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index b05bfa7cc2..ed352d1135 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -327,7 +327,7 @@ $("#po-table").inventreeTable({ { sortable: true, sortName: 'part__MPN', - field: 'supplier_part_detail.MPN', + field: 'supplier_part_detail.manufacturer_part_detail.MPN', title: '{% trans "MPN" %}', formatter: function(value, row, index, field) { if (row.supplier_part_detail && row.supplier_part_detail.manufacturer_part) { From 5b42ab73325d0649d65cee738ad4c30aedcd01fd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 13 Aug 2021 21:48:48 +1000 Subject: [PATCH 04/59] Add "groups" to API forms --- InvenTree/InvenTree/static/css/inventree.css | 7 ++ InvenTree/part/templates/part/category.html | 1 + InvenTree/templates/js/translated/forms.js | 117 ++++++++++++++++++- InvenTree/templates/js/translated/part.js | 55 ++++++--- 4 files changed, 161 insertions(+), 19 deletions(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 592bef396a..74b3b63169 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -730,6 +730,13 @@ padding: 10px; } +.form-panel { + border-radius: 5px; + border: 1px solid #ccc; + padding: 5px; +} + + .modal input { width: 100%; } diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index b149fd28ed..af07952a7e 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -276,6 +276,7 @@ constructForm('{% url "api-part-list" %}', { method: 'POST', fields: fields, + groups: partGroups(), title: '{% trans "Create Part" %}', onSuccess: function(data) { // Follow the new part diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 4b41623fbf..914eb93dec 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 = {}; @@ -413,6 +417,11 @@ function constructFormBody(fields, options) { fields[field].choices = field_options.choices; } + // Group + if (field_options.group) { + fields[field].group = field_options.group; + } + // Field prefix if (field_options.prefix) { fields[field].prefix = field_options.prefix; @@ -465,8 +474,12 @@ 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 += ``; + + console.log(`finally, ending group '${console.current_group}'`); + } // Create a new modal if one does not exists if (!options.modal) { @@ -535,6 +548,8 @@ function constructFormBody(fields, options) { submitFormData(fields, options); } }); + + initializeGroups(fields, options); } @@ -960,6 +975,49 @@ 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"); + } + } +} + + function initializeRelatedFields(fields, options) { var field_names = options.field_names; @@ -1353,6 +1411,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 +1425,62 @@ 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 += ``; + + console.log(`ending group '${options.current_group}'`); + + // 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) { + + console.log(`starting group '${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) { diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 3d8a21c66a..e777968711 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -13,6 +13,26 @@ 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, + } + } + +} + // Construct fieldset for part forms function partFields(options={}) { @@ -48,36 +68,41 @@ 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', }, 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 +116,12 @@ 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: 'decimal', label: '{% trans "Initial Stock Quantity" %}', help_text: '{% trans "Initialize part stock with specified quantity" %}', + group: 'create', }; } @@ -109,21 +130,18 @@ 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', }; } // 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 +149,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 +157,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 +165,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 +212,11 @@ function editPart(pk, options={}) { edit: true }); + var groups = partGroups({}); + constructForm(url, { fields: fields, + groups: partGroups(), title: '{% trans "Edit Part" %}', reload: true, }); @@ -221,6 +245,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) { From 1396c349c85e54e62a2fec6f3d3bf8092289c9c4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 14 Aug 2021 00:08:26 +1000 Subject: [PATCH 05/59] Refactor form field definition copying --- InvenTree/templates/js/translated/forms.js | 79 +++++++--------------- 1 file changed, 23 insertions(+), 56 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 914eb93dec..a5f4f56bc9 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -366,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 for(field in fields) { fields[field].name = field; @@ -383,57 +391,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; - } - - // Group - if (field_options.group) { - fields[field].group = field_options.group; - } - - // 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]; + } } } } @@ -477,8 +446,6 @@ function constructFormBody(fields, options) { if (options.current_group) { // Close out the current group html += `
`; - - console.log(`finally, ending group '${console.current_group}'`); } // Create a new modal if one does not exists @@ -878,6 +845,7 @@ function handleFormErrors(errors, fields, options) { non_field_errors.append( `
{% trans "Form errors exist" %} +
` ); @@ -947,7 +915,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); }); } @@ -1429,8 +1400,6 @@ function constructField(name, parameters, options) { if (options.current_group && parameters.group != options.current_group) { html += `
`; - console.log(`ending group '${options.current_group}'`); - // Null out the current "group" so we can start a new one options.current_group = null; } @@ -1446,8 +1415,6 @@ function constructField(name, parameters, options) { // Add HTML for the start of a separate panel if (parameters.group != options.current_group) { - console.log(`starting group '${group}'`); - html += `
`; From cb11df4dbaa648e52443d1b10c5be1f77ebcd826 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 14 Aug 2021 00:09:08 +1000 Subject: [PATCH 06/59] Improve error checking for initial stock creation when creating a new part - Use @transaction.atomic - Raise proper field errors --- InvenTree/part/api.py | 55 ++++++++++++++++++----- InvenTree/templates/js/translated/part.js | 21 ++++++++- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 789ba9b9b7..dc2be0a378 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -9,12 +9,14 @@ from django.conf.urls import url, include from django.urls import reverse from django.http import JsonResponse 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 rest_framework import status from rest_framework.response import Response from rest_framework import filters, serializers from rest_framework import generics +from rest_framework.exceptions import ValidationError from django_filters.rest_framework import DjangoFilterBackend 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.exceptions import MissingRate -from decimal import Decimal +from decimal import Decimal, InvalidOperation from .models import Part, PartCategory, BomItem from .models import PartParameter, PartParameterTemplate @@ -31,7 +33,9 @@ from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartCategoryParameterTemplate -from stock.models import StockItem + +from stock.models import StockItem, StockLocation + from common.models import InvenTreeSetting from build.models import Build @@ -630,6 +634,7 @@ class PartList(generics.ListCreateAPIView): else: return Response(data) + @transaction.atomic def create(self, request, *args, **kwargs): """ We wish to save the user who created this part! @@ -637,6 +642,8 @@ class PartList(generics.ListCreateAPIView): Note: Implementation copied from DRF class CreateModelMixin """ + #TODO: Unit tests for this function! + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -680,22 +687,50 @@ class PartList(generics.ListCreateAPIView): pass # Optionally create initial stock item - try: - initial_stock = Decimal(request.data.get('initial_stock', 0)) + initial_stock = str2bool(request.data.get('initial_stock', False)) - if initial_stock > 0 and part.default_location is not None: + if initial_stock: + try: + + print("q:", request.data.get('initial_stock_quantity')) + + initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', None)) + + 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')], + }) + + # If an initial stock quantity is specified... + if initial_stock_quantity > 0: + + 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, - location=part.default_location, + quantity=initial_stock_quantity, + location=initial_stock_location, ) stock_item.save(user=request.user) - except: - pass - headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index e777968711..0fd43934fe 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -117,10 +117,29 @@ function partFields(options={}) { delete fields["default_supplier"]; 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', 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', }; } From 5cbb67b91cedb9cf53ba972170fd5cfff0729b7e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 14 Aug 2021 00:20:34 +1000 Subject: [PATCH 07/59] Add options to show / hide form groups --- InvenTree/templates/js/translated/forms.js | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index a5f4f56bc9..b4f1203220 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -842,10 +842,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" %} - + +
` ); @@ -985,6 +987,28 @@ function initializeGroups(fields, options) { } 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); } } From 6218f1c7e699a228f5dd110f0a77c752814ad245 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 14 Aug 2021 00:26:22 +1000 Subject: [PATCH 08/59] Add form elements for initializing a part with supplier data --- InvenTree/templates/js/translated/part.js | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 0fd43934fe..2ae943eae4 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -28,6 +28,11 @@ function partGroups(options={}) { duplicate: { title: '{% trans "Part Duplication Options" %}', collapsible: true, + }, + supplier: { + title: '{% trans "Supplier Options" %}', + collapsible: true, + hidden: !global_settings.PART_PURCHASEABLE, } } @@ -87,6 +92,9 @@ function partFields(options={}) { purchaseable: { value: global_settings.PART_PURCHASEABLE, group: 'attributes', + onEdit: function(value, name, field, options) { + setFormGroupVisibility('supplier', value, options); + } }, salable: { value: global_settings.PART_SALABLE, @@ -151,6 +159,53 @@ function partFields(options={}) { 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 From 78340a71a930bb46ff5f2c064ca9db59ce73be62 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 14 Aug 2021 00:38:08 +1000 Subject: [PATCH 09/59] Adds support for creation of ManufacturerPart and SupplierPart via the Part creation API --- InvenTree/part/api.py | 56 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index dc2be0a378..141d645ec2 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -33,6 +33,7 @@ from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartCategoryParameterTemplate +from company.models import Company, ManufacturerPart, SupplierPart from stock.models import StockItem, StockLocation @@ -731,6 +732,61 @@ class PartList(generics.ListCreateAPIView): stock_item.save(user=request.user) + # Optionally add manufacturer / supplier data to the part + add_supplier_info = str2bool(request.data.get('add_supplier_info', False)) + + if add_supplier_info: + + 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, + manufacturer=manufacturer, + MPN=mpn + ) + else: + # No manufacturer part data specified + manufacturer_part = None + + if supplier or sku: + if not supplier: + raise ValidationError({ + 'supplier': [_("This field is required")] + }) + if not sku: + raise ValidationError({ + 'SKU': [_("This field is required")] + }) + + supplier_part = SupplierPart.objects.create( + part=part, + supplier=supplier, + SKU=sku, + manufacturer_part=manufacturer_part, + ) + headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) From ba1ba67f87677448738055431f8856832a774c5b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 14 Aug 2021 00:46:30 +1000 Subject: [PATCH 10/59] Only add company data if part is purchaseable --- InvenTree/part/api.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 141d645ec2..1573a05538 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -643,7 +643,7 @@ class PartList(generics.ListCreateAPIView): Note: Implementation copied from DRF class CreateModelMixin """ - #TODO: Unit tests for this function! + # TODO: Unit tests for this function! serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -733,9 +733,7 @@ class PartList(generics.ListCreateAPIView): stock_item.save(user=request.user) # Optionally add manufacturer / supplier data to the part - add_supplier_info = str2bool(request.data.get('add_supplier_info', False)) - - if add_supplier_info: + if part.purchaseable and str2bool(request.data.get('add_supplier_info', False)): try: manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None)) @@ -780,7 +778,7 @@ class PartList(generics.ListCreateAPIView): 'SKU': [_("This field is required")] }) - supplier_part = SupplierPart.objects.create( + SupplierPart.objects.create( part=part, supplier=supplier, SKU=sku, From ad844c439368f4d25f6f3a7f3d315473b41584c0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 14 Aug 2021 01:05:06 +1000 Subject: [PATCH 11/59] Simplify rendering of checkboxes in forms - Display "inline" so they take up much less vertical space --- InvenTree/templates/js/translated/forms.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index b4f1203220..37a4f1f1d6 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -490,8 +490,6 @@ function constructFormBody(fields, options) { // Attach clear callbacks (if required) addClearCallbacks(fields, options); - attachToggle(modal); - $(modal + ' .select2-container').addClass('select-full-width'); $(modal + ' .select2-container').css('width', '100%'); @@ -1528,13 +1526,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 @@ -1699,6 +1698,10 @@ function constructInputOptions(name, classes, type, parameters) { opts.push(`placeholder='${parameters.placeholder}'`); } + if (parameters.type == 'boolean') { + opts.push(`style='float: right;'`); + } + if (parameters.multiline) { return ``; } else { @@ -1872,7 +1875,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;' `; + } + + var html = `
${parameters.help_text}
`; return html; } \ No newline at end of file From 2be9399d2ca34559a574f2327753a31b543f8dc2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 14 Aug 2021 01:15:43 +1000 Subject: [PATCH 12/59] CSS style fixes --- InvenTree/templates/js/translated/forms.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 37a4f1f1d6..7576f27670 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -490,6 +490,8 @@ function constructFormBody(fields, options) { // Attach clear callbacks (if required) addClearCallbacks(fields, options); + attachToggle(modal); + $(modal + ' .select2-container').addClass('select-full-width'); $(modal + ' .select2-container').css('width', '100%'); @@ -1699,7 +1701,7 @@ function constructInputOptions(name, classes, type, parameters) { } if (parameters.type == 'boolean') { - opts.push(`style='float: right;'`); + opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`); } if (parameters.multiline) { @@ -1878,7 +1880,7 @@ function constructHelpText(name, parameters, options) { var style = ''; if (parameters.type == 'boolean') { - style = `style='display: inline;' `; + style = `style='display: inline-block; margin-left: 25px' `; } var html = `
${parameters.help_text}
`; From 6eb47096580c346e7638078dc4bb28744146959a Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 14 Aug 2021 10:23:42 +1000 Subject: [PATCH 13/59] Adds initial stock quantity --- InvenTree/templates/js/translated/part.js | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 2ae943eae4..bf9b5f316f 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -135,6 +135,7 @@ function partFields(options={}) { fields.initial_stock_quantity = { type: 'decimal', + value: 1, label: '{% trans "Initial Stock Quantity" %}', help_text: '{% trans "Specify initial stock quantity for this part" %}', group: 'create', From 26c07961cbc1e5362020cac3e6e17a0cc8c6c6ad Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 14 Aug 2021 10:23:57 +1000 Subject: [PATCH 14/59] Bug fix for API --- InvenTree/part/api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 1573a05538..e01d5ecde6 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -693,9 +693,7 @@ class PartList(generics.ListCreateAPIView): if initial_stock: try: - print("q:", request.data.get('initial_stock_quantity')) - - initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', None)) + initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', '')) if initial_stock_quantity <= 0: raise ValidationError({ From 6fa4e330626a72186cf50c92ec7329ada70bf668 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 14 Aug 2021 10:39:05 +1000 Subject: [PATCH 15/59] Unit testing for new API form features --- InvenTree/part/test_api.py | 114 +++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index bbd73b73e0..9f0de61104 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,119 @@ 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) class PartDetailTests(InvenTreeAPITestCase): """ From 2b13512145745f6f2cf699eb1c81ff71a07af348 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 14 Aug 2021 10:43:45 +1000 Subject: [PATCH 16/59] Check that supplier and manufacturer parts are created --- InvenTree/part/api.py | 39 ++++++++++++++++++-------------------- InvenTree/part/test_api.py | 9 +++++++++ 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index e01d5ecde6..34441286ff 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -704,31 +704,28 @@ class PartList(generics.ListCreateAPIView): 'initial_stock_quantity': [_('Must be a valid quantity')], }) - # If an initial stock quantity is specified... - if initial_stock_quantity > 0: + initial_stock_location = request.data.get('initial_stock_location', None) - 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 - 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')], + }) - 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 = StockItem( - part=part, - quantity=initial_stock_quantity, - location=initial_stock_location, - ) - - stock_item.save(user=request.user) + 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)): diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 9f0de61104..ebef21b84b 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -580,6 +580,15 @@ class PartAPITest(InvenTreeAPITestCase): 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): """ Test that we can create / edit / delete Part objects via the API From f72eb4266a6f670b1a56f9eafc4c83246c2f0524 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 14 Aug 2021 12:31:22 +1000 Subject: [PATCH 17/59] remove old debug message --- InvenTree/templates/js/translated/forms.js | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 7576f27670..a11891ff51 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -369,7 +369,6 @@ 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] = {}; } } From f753e11f10f8e310834963eb7976386e4db69b61 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 14 Aug 2021 13:41:19 +1000 Subject: [PATCH 18/59] Improve error notification for modal forms - Scroll to error - Add red border and background to the form --- InvenTree/InvenTree/static/css/inventree.css | 5 ++++ InvenTree/templates/js/translated/forms.js | 29 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 74b3b63169..d9f3b4beac 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -640,6 +640,11 @@ z-index: 9999; } +.modal-error { + border: 2px #F99 solid; + background-color: #faf0f0; +} + .modal-header { border-bottom: 1px solid #ddd; } diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index a11891ff51..d03819a00f 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -516,6 +516,9 @@ function constructFormBody(fields, options) { }); initializeGroups(fields, options); + + // Scroll to the top + $(options.modal).find('.modal-form-content-wrapper').scrollTop(0); } @@ -867,6 +870,8 @@ function handleFormErrors(errors, fields, options) { } } + var first_error_field = null; + for (field_name in errors) { // Add the 'has-error' class @@ -876,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--) { @@ -889,6 +898,26 @@ function handleFormErrors(errors, fields, options) { field_dom.append(html); } } + + var offset = 0; + + 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(offset); + } + + $(options.modal).find('.modal-content').addClass('modal-error'); +} + + +function isFieldVisible(field, options) { + + return $(options.modal).find(`#div_id_${field}`).is(':visible'); } From 32fafc76d7315fb20b57e9d32de89e993af05dbb Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 14 Aug 2021 13:42:50 +1000 Subject: [PATCH 19/59] css tweaks --- InvenTree/InvenTree/static/css/inventree.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index d9f3b4beac..9ed478ea90 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -641,8 +641,8 @@ } .modal-error { - border: 2px #F99 solid; - background-color: #faf0f0; + border: 2px #FCC solid; + background-color: #f5f0f0; } .modal-header { From 28bccea57be3039e0613a1b27545fb44a302796e Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 14 Aug 2021 13:43:38 +1000 Subject: [PATCH 20/59] Cleanup --- InvenTree/templates/js/translated/forms.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index d03819a00f..904053a423 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -899,8 +899,6 @@ function handleFormErrors(errors, fields, options) { } } - var offset = 0; - if (first_error_field) { // Ensure that the field in question is visible document.querySelector(`#div_id_${field_name}`).scrollIntoView({ @@ -908,7 +906,7 @@ function handleFormErrors(errors, fields, options) { }); } else { // Scroll to the top of the form - $(options.modal).find('.modal-form-content-wrapper').scrollTop(offset); + $(options.modal).find('.modal-form-content-wrapper').scrollTop(0); } $(options.modal).find('.modal-content').addClass('modal-error'); From faab1f2464ed2e4f9fdb03ce8a8ef4251004a120 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 15 Aug 2021 11:57:05 +1000 Subject: [PATCH 21/59] Provide global_settings and user_settings as context objects - Adds a new context middleware - Refactor the way that settings are provided to the javascript layer --- InvenTree/InvenTree/context.py | 22 ++++++++++ InvenTree/InvenTree/settings.py | 2 + InvenTree/common/models.py | 42 ++++++------------- .../part/templatetags/inventree_extras.py | 38 +++++++++-------- InvenTree/templates/js/dynamic/settings.js | 11 ++--- 5 files changed, 60 insertions(+), 55 deletions(-) diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 3e1f98ffc2..6f59840967 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -10,6 +10,8 @@ from InvenTree.status_codes import StockHistoryCode import InvenTree.status +import common.models + from users.models import RuleSet @@ -70,6 +72,26 @@ def status_codes(request): } +def inventree_settings(request): + """ + Adds two context objects to the request: + + user_settings - A key:value dict of all user InvenTree settings for the current user + global_settings - A key:value dict of all global InvenTree settings + + Providing a single context object for all settings should reduce the number of db hits + """ + + ctx = {} + + if request.user: + ctx["user_settings"] = common.models.InvenTreeUserSetting.allValues(user=request.user) + + ctx["global_settings"] = common.models.InvenTreeSetting.allValues() + + return ctx + + def user_roles(request): """ Return a map of the current roles assigned to the user. diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 4543b873bd..1ea4815f65 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -320,9 +320,11 @@ TEMPLATES = [ 'django.template.context_processors.i18n', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + # Custom InvenTree context processors 'InvenTree.context.health_status', 'InvenTree.context.status_codes', 'InvenTree.context.user_roles', + 'InvenTree.context.inventree_settings', ], }, }, diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 3924a516f3..d7152ac6ef 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -49,55 +49,37 @@ class BaseInvenTreeSetting(models.Model): are assigned their default values """ - keys = set() - settings = [] - results = cls.objects.all() if user is not None: results = results.filter(user=user) # Query the database + settings = {} + for setting in results: if setting.key: - settings.append({ - "key": setting.key.upper(), - "value": setting.value - }) - - keys.add(setting.key.upper()) + settings[setting.key.upper()] = setting.value # Specify any "default" values which are not in the database for key in cls.GLOBAL_SETTINGS.keys(): - if key.upper() not in keys: + if key.upper() not in settings: - settings.append({ - "key": key.upper(), - "value": cls.get_setting_default(key) - }) - - # Enforce javascript formatting - for idx, setting in enumerate(settings): - - key = setting['key'] - value = setting['value'] + settings[key.upper()] = cls.get_setting_default(key) + for key, value in settings.items(): validator = cls.get_setting_validator(key) - # Convert to javascript compatible booleans if cls.validator_is_bool(validator): - value = str(value).lower() - - # Numerical values remain the same + value = InvenTree.helpers.str2bool(value) elif cls.validator_is_int(validator): - pass + try: + value = int(value) + except ValueError: + value = cls.get_setting_default(key) - # Wrap strings with quotes - else: - value = format_html("'{}'", value) - - setting["value"] = value + settings[key] = value return settings diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index b12a59f136..054cd0be95 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 @@ -208,24 +209,6 @@ def settings_value(key, *args, **kwargs): return InvenTreeSetting.get_setting(key) -@register.simple_tag() -def user_settings(user, *args, **kwargs): - """ - Return all USER settings as a key:value dict - """ - - return InvenTreeUserSetting.allValues(user=user) - - -@register.simple_tag() -def global_settings(*args, **kwargs): - """ - Return all GLOBAL InvenTree settings as a key:value dict - """ - - return InvenTreeSetting.allValues() - - @register.simple_tag() def get_color_theme_css(username): try: @@ -262,6 +245,25 @@ 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/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index ad4e297c4a..3f96e9d988 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -1,17 +1,14 @@ {% load inventree_extras %} // 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 %} }; 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 From cef09acd543e5f3fc637d4648f6955dddea3d465 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 15 Aug 2021 12:05:53 +1000 Subject: [PATCH 22/59] Partial reversion of some stuff --- InvenTree/InvenTree/context.py | 20 ------------------- InvenTree/InvenTree/settings.py | 1 - .../part/templatetags/inventree_extras.py | 18 +++++++++++++++++ InvenTree/templates/js/dynamic/settings.js | 8 ++++++-- 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 6f59840967..85a320afb4 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -72,26 +72,6 @@ def status_codes(request): } -def inventree_settings(request): - """ - Adds two context objects to the request: - - user_settings - A key:value dict of all user InvenTree settings for the current user - global_settings - A key:value dict of all global InvenTree settings - - Providing a single context object for all settings should reduce the number of db hits - """ - - ctx = {} - - if request.user: - ctx["user_settings"] = common.models.InvenTreeUserSetting.allValues(user=request.user) - - ctx["global_settings"] = common.models.InvenTreeSetting.allValues() - - return ctx - - def user_roles(request): """ Return a map of the current roles assigned to the user. diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 1ea4815f65..96b7da140d 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -324,7 +324,6 @@ TEMPLATES = [ 'InvenTree.context.health_status', 'InvenTree.context.status_codes', 'InvenTree.context.user_roles', - 'InvenTree.context.inventree_settings', ], }, }, diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 054cd0be95..f36746e7a2 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -209,6 +209,24 @@ def settings_value(key, *args, **kwargs): return InvenTreeSetting.get_setting(key) +@register.simple_tag() +def user_settings(user, *args, **kwargs): + """ + Return all USER settings as a key:value dict + """ + + return InvenTreeUserSetting.allValues(user=user) + + +@register.simple_tag() +def global_settings(*args, **kwargs): + """ + Return all GLOBAL InvenTree settings as a key:value dict + """ + + return InvenTreeSetting.allValues() + + @register.simple_tag() def get_color_theme_css(username): try: diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index 3f96e9d988..60172ead64 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -1,14 +1,18 @@ {% load inventree_extras %} // InvenTree settings +{% user_settings request.user as USER_SETTINGS %} + var user_settings = { - {% for key, value in user_settings.items %} + {% for key, value in USER_SETTINGS.items %} {{ key }}: {% primitive_to_javascript value %}, {% endfor %} }; +{% global_settings as GLOBAL_SETTINGS %} + var global_settings = { - {% for key, value in global_settings.items %} + {% for key, value in GLOBAL_SETTINGS.items %} {{ key }}: {% primitive_to_javascript value %}, {% endfor %} }; \ No newline at end of file From 8861ffad81512626cc327cc68bd7a25a9ff001ed Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 15 Aug 2021 12:06:31 +1000 Subject: [PATCH 23/59] PEP fixes --- InvenTree/InvenTree/context.py | 2 -- InvenTree/common/models.py | 1 - InvenTree/part/templatetags/inventree_extras.py | 1 + 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 85a320afb4..3e1f98ffc2 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -10,8 +10,6 @@ from InvenTree.status_codes import StockHistoryCode import InvenTree.status -import common.models - from users.models import RuleSet diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index d7152ac6ef..cf5e44595a 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -20,7 +20,6 @@ from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate from django.utils.translation import ugettext_lazy as _ -from django.utils.html import format_html from django.core.validators import MinValueValidator, URLValidator from django.core.exceptions import ValidationError diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index f36746e7a2..3b88deb504 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -282,6 +282,7 @@ def primitive_to_javascript(primitive): # Wrap with quotes return format_html("'{}'", primitive) + @register.filter def keyvalue(dict, key): """ From 1998dabe9b3406c694f38b9e1d7105192136618b Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 15 Aug 2021 21:47:37 +1000 Subject: [PATCH 24/59] Small tweaks here and there --- .../templates/js/translated/attachment.js | 27 +++++++++++++++++-- InvenTree/templates/js/translated/bom.js | 2 +- InvenTree/templates/js/translated/part.js | 2 +- InvenTree/templates/js/translated/stock.js | 2 +- 4 files changed, 28 insertions(+), 5 deletions(-) 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/part.js b/InvenTree/templates/js/translated/part.js index bf9b5f316f..93fb7066a4 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -500,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`); } } ]; 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 = ''; From ff8dcabb12029ebb3ea82e6b1b0620d692e21c73 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 15 Aug 2021 22:43:52 +1000 Subject: [PATCH 25/59] New custom serializer for handling attachments --- InvenTree/InvenTree/models.py | 14 ++++++++++++ InvenTree/InvenTree/serializers.py | 26 +++++++++++++++++++++++ InvenTree/part/serializers.py | 6 +++++- InvenTree/part/templates/part/detail.html | 1 + 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 3213838e78..35b9c3ff61 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -55,6 +55,20 @@ class InvenTreeAttachment(models.Model): return "attachments" + def get_filename(self): + + return os.path.basename(self.attachment.name) + + def rename(self, filename): + """ + Rename this attachment with the provided filename. + + - Filename cannot be empty + - Filename must have an extension + - Filename will have random data appended if a file exists with the same name + """ + pass + def __str__(self): return os.path.basename(self.attachment.name) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index baf08e112b..a8c02a1217 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -208,6 +208,32 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return data +class InvenTreeAttachmentSerializer(InvenTreeModelSerializer): + """ + Special case of an InvenTreeModelSerializer, which handles an "attachment" model. + + The only real addition here is that we support "renaming" of the attachment file. + """ + + # The 'filename' field must be present in the serializer + filename = serializers.CharField( + label=_('Filename'), + required=False, + source='get_filename', + ) + + def update(self, instance, validated_data): + """ + Filename can only be edited on "update" + """ + + instance = super().update(instance, validated_data) + + print(validated_data) + + return instance + + class InvenTreeAttachmentSerializerField(serializers.FileField): """ Override the DRF native FileField serializer, diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index e2c8c3fa4d..c2d515cf32 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -1,6 +1,7 @@ """ JSON serializers for Part app """ + import imghdr from decimal import Decimal @@ -16,7 +17,9 @@ from djmoney.contrib.django_rest_framework import MoneyField from InvenTree.serializers import (InvenTreeAttachmentSerializerField, InvenTreeImageSerializerField, InvenTreeModelSerializer, + InvenTreeAttachmentSerializer, InvenTreeMoneySerializer) + from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem @@ -51,7 +54,7 @@ class CategorySerializer(InvenTreeModelSerializer): ] -class PartAttachmentSerializer(InvenTreeModelSerializer): +class PartAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializer for the PartAttachment class """ @@ -65,6 +68,7 @@ class PartAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'part', 'attachment', + 'filename', 'comment', 'upload_date', ] diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 846320b8e1..80e4a77d1b 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -868,6 +868,7 @@ constructForm(url, { fields: { + filename: {}, comment: {}, }, title: '{% trans "Edit Attachment" %}', From 3dcf1746e6251190abe018ebac4abdec5097e0b5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 10:41:02 +1000 Subject: [PATCH 26/59] Functionality for renaming attached files --- InvenTree/InvenTree/models.py | 85 +++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 35b9c3ff61..2ca179bb40 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -5,8 +5,10 @@ Generic models which provide extra functionality over base Django model types. from __future__ import unicode_literals import os +import logging from django.db import models +from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ @@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove from .validators import validate_tree_name +logger = logging.getLogger('inventree') + + def rename_attachment(instance, filename): """ Function for renaming an attachment file. @@ -55,20 +60,6 @@ class InvenTreeAttachment(models.Model): return "attachments" - def get_filename(self): - - return os.path.basename(self.attachment.name) - - def rename(self, filename): - """ - Rename this attachment with the provided filename. - - - Filename cannot be empty - - Filename must have an extension - - Filename will have random data appended if a file exists with the same name - """ - pass - def __str__(self): return os.path.basename(self.attachment.name) @@ -91,6 +82,72 @@ class InvenTreeAttachment(models.Model): def basename(self): return os.path.basename(self.attachment.name) + @basename.setter + def basename(self, fn): + """ + Function to rename the attachment file. + + - Filename cannot be empty + - Filename cannot contain illegal characters + - Filename must specify an extension + - Filename cannot match an existing file + """ + + fn = fn.strip() + + if len(fn) == 0: + raise ValidationError(_('Filename must not be empty')) + + attachment_dir = os.path.join( + settings.MEDIA_ROOT, + self.getSubdir() + ) + + old_file = os.path.join( + settings.MEDIA_ROOT, + self.attachment.name + ) + + new_file = os.path.join( + settings.MEDIA_ROOT, + self.getSubdir(), + fn + ) + + new_file = os.path.abspath(new_file) + + # Check that there are no directory tricks going on... + if not os.path.dirname(new_file) == attachment_dir: + logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'") + raise ValidationError(_("Invalid attachment directory")) + + # Ignore further checks if the filename is not actually being renamed + if new_file == old_file: + return + + forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"] + + for c in forbidden: + if c in fn: + raise ValidationError(_(f"Filename contains illegal character '{c}'")) + + if len(fn.split('.')) < 2: + raise ValidationError(_("Filename missing extension")) + + if not os.path.exists(old_file): + logger.error(f"Trying to rename attachment '{old_file}' which does not exist") + return + + if os.path.exists(new_file): + raise ValidationError(_("Attachment with this filename already exists")) + + try: + os.rename(old_file, new_file) + self.attachment.name = os.path.join(self.getSubdir(), fn) + self.save() + except: + raise ValidationError(_("Error renaming file")) + class Meta: abstract = True From d9f29b4a702c0ddb9880a9caa607be1d4f122a8f Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 10:41:26 +1000 Subject: [PATCH 27/59] Updates for InvenTree serializer classes - Catch and re-throw errors correctly --- InvenTree/InvenTree/serializers.py | 31 +++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index a8c02a1217..b156e39167 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -167,6 +167,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return self.instance + def update(self, instance, validated_data): + """ + Catch any django ValidationError, and re-throw as a DRF ValidationError + """ + + try: + instance = super().update(instance, validated_data) + except (ValidationError, DjangoValidationError) as exc: + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + return instance + def run_validation(self, data=empty): """ Perform serializer validation. @@ -188,7 +200,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): # Update instance fields for attr, value in data.items(): - setattr(instance, attr, value) + try: + setattr(instance, attr, value) + except (ValidationError, DjangoValidationError) as exc: + raise ValidationError(detail=serializers.as_serializer_error(exc)) # Run a 'full_clean' on the model. # Note that by default, DRF does *not* perform full model validation! @@ -219,20 +234,10 @@ class InvenTreeAttachmentSerializer(InvenTreeModelSerializer): filename = serializers.CharField( label=_('Filename'), required=False, - source='get_filename', + source='basename', + allow_blank=False, ) - def update(self, instance, validated_data): - """ - Filename can only be edited on "update" - """ - - instance = super().update(instance, validated_data) - - print(validated_data) - - return instance - class InvenTreeAttachmentSerializerField(serializers.FileField): """ From f8b22bc7b7368e040dd972310094eba6b9ee6fea Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 10:49:31 +1000 Subject: [PATCH 28/59] Refactor BuildAttachment model --- InvenTree/build/serializers.py | 6 ++++-- InvenTree/build/templates/build/detail.html | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 5c0fced884..69e3a7aed0 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -10,7 +10,8 @@ from django.db.models import BooleanField from rest_framework import serializers -from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializerField, UserSerializerBrief +from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer +from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief from stock.serializers import StockItemSerializerBrief from stock.serializers import LocationSerializer @@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer): ] -class BuildAttachmentSerializer(InvenTreeModelSerializer): +class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializer for a BuildAttachment """ @@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'build', 'attachment', + 'filename', 'comment', 'upload_date', ] diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index fe716b87f2..d6b59a060d 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -369,6 +369,7 @@ loadAttachmentTable( constructForm(url, { fields: { + filename: {}, comment: {}, }, onSuccess: reloadAttachmentTable, From 6141ddc3ebec840b02ac817222b249924387c896 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 10:53:28 +1000 Subject: [PATCH 29/59] SalesOrderAttachment and PurchaseOrderAttachment --- InvenTree/order/serializers.py | 7 +++++-- InvenTree/order/templates/order/purchase_order_detail.html | 1 + InvenTree/order/templates/order/sales_order_detail.html | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 4a95bbb166..e97d19250a 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -14,6 +14,7 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount from InvenTree.serializers import InvenTreeModelSerializer +from InvenTree.serializers import InvenTreeAttachmentSerializer from InvenTree.serializers import InvenTreeMoneySerializer from InvenTree.serializers import InvenTreeAttachmentSerializerField @@ -160,7 +161,7 @@ class POLineItemSerializer(InvenTreeModelSerializer): ] -class POAttachmentSerializer(InvenTreeModelSerializer): +class POAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the PurchaseOrderAttachment model """ @@ -174,6 +175,7 @@ class POAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'order', 'attachment', + 'filename', 'comment', 'upload_date', ] @@ -381,7 +383,7 @@ class SOLineItemSerializer(InvenTreeModelSerializer): ] -class SOAttachmentSerializer(InvenTreeModelSerializer): +class SOAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializers for the SalesOrderAttachment model """ @@ -395,6 +397,7 @@ class SOAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'order', 'attachment', + 'filename', 'comment', 'upload_date', ] diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index ed352d1135..586ce73f14 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -122,6 +122,7 @@ constructForm(url, { fields: { + filename: {}, comment: {}, }, onSuccess: reloadAttachmentTable, diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 277c1f4278..30799e2296 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -112,6 +112,7 @@ constructForm(url, { fields: { + filename: {}, comment: {}, }, onSuccess: reloadAttachmentTable, From 23b2b56de4c9c8e2b57f13735a085502a69555ef Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 10:56:00 +1000 Subject: [PATCH 30/59] StockItemAttachment --- InvenTree/stock/serializers.py | 5 +++-- InvenTree/stock/templates/stock/item.html | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) 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" %}', From 99839e78fd63d110814b7754c4121f370549e1a3 Mon Sep 17 00:00:00 2001 From: eeintech Date: Mon, 16 Aug 2021 10:21:57 -0400 Subject: [PATCH 31/59] Added navbar on part import page --- .../part/import_wizard/part_upload.html | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/templates/part/import_wizard/part_upload.html b/InvenTree/part/templates/part/import_wizard/part_upload.html index 676053bbe5..b898cfbc98 100644 --- a/InvenTree/part/templates/part/import_wizard/part_upload.html +++ b/InvenTree/part/templates/part/import_wizard/part_upload.html @@ -1,8 +1,24 @@ -{% extends "base.html" %} +{% extends "part/part_app_base.html" %} {% load inventree_extras %} {% load i18n %} {% load static %} +{% block menubar %} + +{% endblock %} + {% block content %}
@@ -54,4 +70,9 @@ {% block js_ready %} {{ block.super }} +enableNavbar({ + label: 'part', + toggleId: '#part-menu-toggle', +}); + {% endblock %} \ No newline at end of file From d8eefec0653959d1a416517031684ea74c6908a8 Mon Sep 17 00:00:00 2001 From: Guusggg Date: Tue, 17 Aug 2021 04:42:40 +0200 Subject: [PATCH 32/59] Print multi part label (#1963) * Added description as list for StockLocation * Merge pull request #1874 from SchrodingersGat/docker-dev-fix Copy static files when starting dev server (cherry picked from commit 50eb70f538a0788a4e565db0b4a5775a2ee5bf78) * Merge pull request #1877 from eeintech/fix_search_js Fixed missing comma propagating to translated JS files (cherry picked from commit 2009773d9dca7ee309e70e14bd9aa656b54843c9) * Merge pull request #1890 from matmair/fix-for-1888 catch connection errors in exchange update (cherry picked from commit db57e9516bbb53f008a970331b83939c7e007d57) * Merge pull request #1887 from matmair/settings-safety settings fixes (cherry picked from commit d154ca08ea31f990b8de765cce211b362914afb2) * 0.4.2 * Merge pull request #1894 from SchrodingersGat/non-int-serial-fix Fix for non-integer serial numbers (cherry picked from commit 529742b5203005d5d71921c1ee32cd1bc540af4d) * 0.4.4 Bump release version * Bump version number -> 0.4.5 * Added a simple menu item to print multiple part labels. This does not follow the style of the Stock label functions but it works! * Revert "Added description as list for StockLocation" This reverts commit f5178e9fc36b9c93dc764ceab0c8668ec97cc34a. * Added the right version number Co-authored-by: Oliver --- InvenTree/part/templates/part/category.html | 3 ++- InvenTree/templates/js/translated/part.js | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index af07952a7e..44e3ee0daa 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -138,6 +138,7 @@
  • {% trans "Set Category" %}
  • {% endif %}
  • {% trans "Order Parts" %}
  • +
  • {% trans "Print Labels" %}
  • {% trans "Export Data" %}
  • @@ -337,4 +338,4 @@ default: 'part-stock' }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 93fb7066a4..4ed631fe61 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -1003,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"); From 92aace12784dc26fe951a3bea0f2b195f135fadc Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 18:22:07 +1000 Subject: [PATCH 33/59] Run translation step as part of "update" --- tasks.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/tasks.py b/tasks.py index a9168f4649..b3aaab2f92 100644 --- a/tasks.py +++ b/tasks.py @@ -175,22 +175,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): """ @@ -208,6 +192,25 @@ def translate(c): c.run(f'python {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 +220,7 @@ def style(c): print("Running PEP style checks...") c.run('flake8 InvenTree') + @task def test(c, database=None): """ @@ -228,6 +232,7 @@ def test(c, database=None): # Run coverage tests manage(c, 'test', pty=True) + @task def coverage(c): """ From 206743b58d5142bbc2d086f8bbbedd0f705cdc3c Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 19:58:55 +1000 Subject: [PATCH 34/59] Add a default value for INVENTREE_WEB_ADDR --- docker/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8f57d8a18c..c4f0b36492 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,7 +35,8 @@ 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 +# 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" \ From 07857c3088ca119d42edf4ed7d5a98b53874cb51 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 19:59:32 +1000 Subject: [PATCH 35/59] Simplify dev-config.env file - Don't need to re-specify the internal docker variables - Add comments --- docker/dev-config.env | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docker/dev-config.env b/docker/dev-config.env index fe1f073633..ec00c47251 100644 --- a/docker/dev-config.env +++ b/docker/dev-config.env @@ -1,9 +1,10 @@ +# Set DEBUG to False for a production environment! +INVENTREE_DEBUG=true + +# 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 From 7bf32295951da909820c01eb87f50e7cf10898aa Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 20:00:54 +1000 Subject: [PATCH 36/59] Add comment to docker-compose file --- docker/docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 29eccc26c6..ba8ed774ee 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 From a474000361f48cfb24b209b38d3063c10b21002f Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 20:29:48 +1000 Subject: [PATCH 37/59] Fix critical error in dockerfile - Don't' be putting no spaces in! --- docker/Dockerfile | 2 +- docker/dev-config.env | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c4f0b36492..1266a282e4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -112,7 +112,7 @@ 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" # Override default path settings ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static" diff --git a/docker/dev-config.env b/docker/dev-config.env index ec00c47251..67810291aa 100644 --- a/docker/dev-config.env +++ b/docker/dev-config.env @@ -1,5 +1,7 @@ +# InvenTree environment variables for a development setup + # Set DEBUG to False for a production environment! -INVENTREE_DEBUG=true +INVENTREE_DEBUG=True # Database linking options INVENTREE_DB_ENGINE=sqlite3 From d5d89c67b1da9258e17848c3c3e62d0bd4f1bb65 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 20:42:19 +1000 Subject: [PATCH 38/59] Error out if the static or media directories are not properly defined --- InvenTree/InvenTree/settings.py | 40 ++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 96b7da140d..6297dca41d 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -169,6 +169,30 @@ else: logger.exception(f"Couldn't load keyfile {key_file}") sys.exit(-1) +# The filesystem location for served static files +STATIC_ROOT = os.path.abspath( + get_setting( + 'INVENTREE_STATIC_ROOT', + CONFIG.get('static_root', None) + ) +) + +if STATIC_ROOT is None: + print("ERROR: INVENTREE_STATIC_ROOT directory not defined") + sys.exit(1) + +# The filesystem location for served static files +MEDIA_ROOT = os.path.abspath( + get_setting( + 'INVENTREE_MEDIA_ROOT', + CONFIG.get('media_root', None) + ) +) + +if MEDIA_ROOT is None: + print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined") + sys.exit(1) + # List of allowed hosts (default = allow all) ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*']) @@ -189,14 +213,6 @@ if cors_opt: # Web URL endpoint for served static files STATIC_URL = '/static/' -# The filesystem location for served static files -STATIC_ROOT = os.path.abspath( - get_setting( - 'INVENTREE_STATIC_ROOT', - CONFIG.get('static_root', '/home/inventree/data/static') - ) -) - STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'InvenTree', 'static'), ] @@ -218,14 +234,6 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes') # Web URL endpoint for served media files MEDIA_URL = '/media/' -# The filesystem location for served static files -MEDIA_ROOT = os.path.abspath( - get_setting( - 'INVENTREE_MEDIA_ROOT', - CONFIG.get('media_root', '/home/inventree/data/media') - ) -) - if DEBUG: logger.info("InvenTree running in DEBUG mode") From 895f9f3ce015152c93ad3bb20b8034c765ac82b4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 20:45:57 +1000 Subject: [PATCH 39/59] Pull debug level out into the .env file --- docker/dev-config.env | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/dev-config.env b/docker/dev-config.env index 67810291aa..927f505649 100644 --- a/docker/dev-config.env +++ b/docker/dev-config.env @@ -3,6 +3,9 @@ # 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 From 0294a1c323702471c409876e2744a4b35055b355 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 21:02:45 +1000 Subject: [PATCH 40/59] Fix for staticfile collection - Was generating a *lot* of warning messages - Ref: https://github.com/django-compressor/django-compressor/issues/720 --- InvenTree/InvenTree/settings.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 6297dca41d..a0a9ca510e 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -213,14 +213,12 @@ if cors_opt: # Web URL endpoint for served static files STATIC_URL = '/static/' -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'InvenTree', 'static'), -] +STATICFILES_DIRS = [] # Translated Template settings STATICFILES_I18_PREFIX = 'i18n' STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated') -STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX +STATICFILES_I18_TRG = os.path.join(BASE_DIR, 'InvenTree', 'static_i18n') STATICFILES_DIRS.append(STATICFILES_I18_TRG) STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX) From 8b66babd492b37bf26f5bf494d64a5fada8a0212 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 22:58:44 +1000 Subject: [PATCH 41/59] Refactor dockerfile - Ref: https://github.com/inventree/InvenTree/pull/1949 - Squash all apk commands into single line - Drop to inventree user rather than running as root - Separate entrypoint and cmd for each target - Set the INVENTREE_PY_ENV variable in development mode --- docker/Dockerfile | 69 +++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1266a282e4..3d86d73eca 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -59,28 +59,27 @@ 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 - -# 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 + 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 # Install required python packages RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb +ENTRYPOINT ["/sbin/tini", "--"] + FROM base as production + # Clone source code RUN echo "Downloading InvenTree from ${INVENTREE_GIT_REPO}" @@ -89,23 +88,22 @@ 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 -# Install InvenTree packages -RUN pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt - -# Copy gunicorn config file -COPY gunicorn.conf.py ${INVENTREE_HOME}/gunicorn.conf.py - -# 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 - -RUN chmod 755 ${INVENTREE_HOME}/start_prod_server.sh -RUN chmod 755 ${INVENTREE_HOME}/start_prod_worker.sh +RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/* WORKDIR ${INVENTREE_HOME} -# Let us begin -CMD ["bash", "./start_prod_server.sh"] +# Drop to the inventree user +USER inventree + +# Install InvenTree packages +RUN pip3 install --no-cache-dir --disable-pip-version-check pip==21.2.3 setuptools==57.4.0 wheel>=0.37.0 \ + && pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt + +# Server init entrypoint +ENTRYPOINT ./docker/init-server.sh + +# Launch the production server +CMD ["gunicorn -c ./docker/gunicorn.conf.py -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} InvenTree.wsgi"] FROM base as dev @@ -114,6 +112,10 @@ FROM base as 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_HOME}/dev/env" + # Override default path settings ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static" ENV INVENTREE_MEDIA_ROOT="${INVENTREE_DEV_DIR}/media" @@ -122,5 +124,8 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt" WORKDIR ${INVENTREE_HOME} +# Entrypoint +ENTRYPOINT ./docker/init-server.sh + # Launch the development server -CMD ["bash", "/home/inventree/docker/start_dev_server.sh"] +CMD ["python3 manage.py runserver ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"] From 187c9b09716d9bd7f50bb1437322874fb66968f7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 23:10:57 +1000 Subject: [PATCH 42/59] Add server init script - Taken (mostly) from https://github.com/inventree/InvenTree/pull/1949 --- docker/Dockerfile | 2 +- docker/init-server.sh | 69 +++++++++++++++++++++++++++++++++++++++++++ tasks.py | 2 +- 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 docker/init-server.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index 3d86d73eca..26d57e4e47 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -114,7 +114,7 @@ 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_HOME}/dev/env" +ENV INVENTREE_PY_ENV="${INVENTREE_DEV}/env" # Override default path settings ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static" diff --git a/docker/init-server.sh b/docker/init-server.sh new file mode 100644 index 0000000000..48603815fc --- /dev/null +++ b/docker/init-server.sh @@ -0,0 +1,69 @@ +#!/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 + + echo "Installing required packages..." + pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt +fi + +# Wait for the InvenTree database to be ready +cd ${INVENTREE_MNG_DIR} +echo "InvenTree: Waiting for database connection" +python3 manage.py wait_for_db && echo "InvenTree: db found, sleeping 10" || { echo "InvenTree: Failed to connect to db due to errors, aborting"; exit 1; } +sleep 10 + +# Check database migrations +cd ${INVENTREE_HOME} + +# We assume at this stage that the database is up and running +# Ensure that the database schema are up to date +echo "InvenTree: Checking database..." +invoke check || exit 1 +echo "InvenTree: Check successful" +echo "InvenTree: Database Migrations..." +invoke migrate || exit 1 +echo "InvenTree: Migrations successful" +echo "InvenTree: Collecting static files..." +# Note: "translate" calls "static" also +invoke translate || exit 1 +echo "InvenTree: static successful" + +# Can be run as a cron job or directly to clean out expired sessions. +cd ${INVENTREE_MNG_DIR} +python3 manage.py clearsessions || exit 1 +echo "InvenTree: migrations complete" + +#Launch the CMD +#echo "init-server launching $@" +#exec "$@" +#echo "init-server exiting" diff --git a/tasks.py b/tasks.py index b3aaab2f92..837207f1ec 100644 --- a/tasks.py +++ b/tasks.py @@ -156,7 +156,7 @@ def migrate(c): print("========================================") manage(c, "makemigrations") - manage(c, "migrate") + manage(c, "migrate --noinput") manage(c, "migrate --run-syncdb") manage(c, "check") From b48db6f8fe84d783f70c1ffaf118819c83245d9b Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 17 Aug 2021 23:15:05 +1000 Subject: [PATCH 43/59] Dockerfile fixes --- docker/Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 26d57e4e47..5fb7329498 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -96,8 +96,7 @@ WORKDIR ${INVENTREE_HOME} USER inventree # Install InvenTree packages -RUN pip3 install --no-cache-dir --disable-pip-version-check pip==21.2.3 setuptools==57.4.0 wheel>=0.37.0 \ - && pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt +RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt # Server init entrypoint ENTRYPOINT ./docker/init-server.sh @@ -114,7 +113,7 @@ 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}/env" +ENV INVENTREE_PY_ENV="${INVENTREE_DEV_DIR}/env" # Override default path settings ENV INVENTREE_STATIC_ROOT="${INVENTREE_DEV_DIR}/static" From da834d8bcc37e4f67bd039748935b2f52e48ce0a Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 00:04:38 +1000 Subject: [PATCH 44/59] Reduce cruft in logs --- InvenTree/InvenTree/settings.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index a0a9ca510e..f3c166df88 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -235,8 +235,8 @@ MEDIA_URL = '/media/' if DEBUG: logger.info("InvenTree running in DEBUG mode") -logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'") -logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'") +logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'") +logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'") # Application definition @@ -420,7 +420,7 @@ Configure the database backend based on the user-specified values. - The following code lets the user "mix and match" database configuration """ -logger.info("Configuring database backend:") +logger.debug("Configuring database backend:") # Extract database configuration from the config.yaml file db_config = CONFIG.get('database', {}) @@ -474,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']: db_name = db_config['NAME'] db_host = db_config.get('HOST', "''") -print("InvenTree Database Configuration") -print("================================") -print(f"ENGINE: {db_engine}") -print(f"NAME: {db_name}") -print(f"HOST: {db_host}") +logger.info(f"DB_ENGINE: {db_engine}") +logger.info(f"DB_NAME: {db_name}") +logger.info(f"DB_HOST: {db_host}") DATABASES['default'] = db_config From 3b8ee485813c67550b63d658bce164f748ed6abd Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 09:34:09 +1000 Subject: [PATCH 45/59] Fix env defines in dockerfile --- docker/Dockerfile | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5fb7329498..d68236b91f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -36,18 +36,15 @@ ENV INVENTREE_GUNICORN_WORKERS="4" ENV INVENTREE_BACKGROUND_WORKERS="4" # Default web server address:port -ENV INVENTREE_WEB_ADDR="0.0.0.0" -ENV INVENTREE_WEB_PORT="8000" +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_REPO} # Create user account RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup From 7bfddd6d51f1b086a0eef8520ed0fd32fd60cdff Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 09:52:27 +1000 Subject: [PATCH 46/59] Simplify init scripts Single script init.sh which performs the following tasks: - Creates required directory structure - Activates python venv (if required) - Waits for database connection - Runs command --- docker/Dockerfile | 11 +++---- docker/docker-compose.dev.yml | 2 +- docker/{init-server.sh => init.sh} | 37 +++++++--------------- docker/start_dev_server.sh | 51 ------------------------------ docker/start_dev_worker.sh | 19 ----------- docker/start_prod_server.sh | 42 ------------------------ docker/start_prod_worker.sh | 14 -------- tasks.py | 34 ++++++++++---------- 8 files changed, 34 insertions(+), 176 deletions(-) rename docker/{init-server.sh => init.sh} (57%) delete mode 100644 docker/start_dev_server.sh delete mode 100644 docker/start_dev_worker.sh delete mode 100644 docker/start_prod_server.sh delete mode 100644 docker/start_prod_worker.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index d68236b91f..5a9059ec7f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -73,8 +73,6 @@ RUN apk add --no-cache git make bash \ # Install required python packages RUN pip install --no-cache-dir -U psycopg2 mysqlclient pgcli mariadb -ENTRYPOINT ["/sbin/tini", "--"] - FROM base as production # Clone source code @@ -96,7 +94,7 @@ USER inventree RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt # Server init entrypoint -ENTRYPOINT ./docker/init-server.sh +ENTRYPOINT ["/bin/bash", "./docker/init.sh"] # Launch the production server CMD ["gunicorn -c ./docker/gunicorn.conf.py -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} InvenTree.wsgi"] @@ -120,8 +118,9 @@ ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DEV_DIR}/secret_key.txt" WORKDIR ${INVENTREE_HOME} -# Entrypoint -ENTRYPOINT ./docker/init-server.sh +# Entrypoint ensures that we are running in the python virtual environment +ENTRYPOINT ["/bin/bash", "./docker/init.sh"] # Launch the development server -CMD ["python3 manage.py runserver ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"] +CMD ["invoke", "server", "-a", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}"] + diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index ba8ed774ee..c4be092189 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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/init-server.sh b/docker/init.sh similarity index 57% rename from docker/init-server.sh rename to docker/init.sh index 48603815fc..9be8ffe44b 100644 --- a/docker/init-server.sh +++ b/docker/init.sh @@ -32,38 +32,25 @@ if [[ -n "$INVENTREE_PY_ENV" ]]; then # Activate the virtual environment source ${INVENTREE_PY_ENV}/bin/activate - echo "Installing required packages..." - pip install --no-cache-dir -U -r ${INVENTREE_HOME}/requirements.txt + # 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 # Wait for the InvenTree database to be ready -cd ${INVENTREE_MNG_DIR} -echo "InvenTree: Waiting for database connection" -python3 manage.py wait_for_db && echo "InvenTree: db found, sleeping 10" || { echo "InvenTree: Failed to connect to db due to errors, aborting"; exit 1; } -sleep 10 +# cd ${INVENTREE_MNG_DIR} +# echo "InvenTree: Waiting for database connection" +# invoke wait && echo "InvenTree: Database connection successful" || { echo "InvenTree: Failed to connect to db due to errors, aborting"; exit 1; } +# sleep 5 -# Check database migrations cd ${INVENTREE_HOME} # We assume at this stage that the database is up and running -# Ensure that the database schema are up to date -echo "InvenTree: Checking database..." -invoke check || exit 1 -echo "InvenTree: Check successful" -echo "InvenTree: Database Migrations..." -invoke migrate || exit 1 -echo "InvenTree: Migrations successful" -echo "InvenTree: Collecting static files..." -# Note: "translate" calls "static" also -invoke translate || exit 1 -echo "InvenTree: static successful" +# echo "InvenTree: Checking database..." +# invoke check || exit 1 # Can be run as a cron job or directly to clean out expired sessions. -cd ${INVENTREE_MNG_DIR} -python3 manage.py clearsessions || exit 1 -echo "InvenTree: migrations complete" +# cd ${INVENTREE_MNG_DIR} +# python3 manage.py clearsessions || exit 1 -#Launch the CMD -#echo "init-server launching $@" -#exec "$@" -#echo "init-server exiting" +# Launch the CMD *after* the ENTRYPOINT completes +exec "$@" 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/tasks.py b/tasks.py index 837207f1ec..9088efb12f 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. From 8fea9bc645cef828443c707daf034909863af1c0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 11:25:19 +1000 Subject: [PATCH 47/59] Re-add docker file git version info --- docker/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5a9059ec7f..b6d59de206 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -44,7 +44,9 @@ LABEL org.label-schema.schema-version="1.0" \ 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.vcs-url=${INVENTREE_REPO} + org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \ + org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \ + org.label-schema.vss-ref=${INVENTREE_GIT_TAG} # Create user account RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup From c2af4018546ce7eb5167c993bd7c7d27c262a265 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 12:03:24 +1000 Subject: [PATCH 48/59] Pin base python package requirements - Require invoke to be installed before we can run "invoke update" --- docker/Dockerfile | 6 +++--- docker/requirements.txt | 13 +++++++++++++ requirements.txt | 8 +------- 3 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 docker/requirements.txt diff --git a/docker/Dockerfile b/docker/Dockerfile index b6d59de206..9cfe88b2b5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -72,9 +72,9 @@ RUN apk add --no-cache git make bash \ # MySQL/MariaDB support 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 FROM base as production # Clone source code 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/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 From c1ea6dbb9b753603f80df568a5c5f11261fa0c6b Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 12:28:09 +1000 Subject: [PATCH 49/59] Remove commented out functionality from the entrypoint command --- docker/init.sh | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docker/init.sh b/docker/init.sh index 9be8ffe44b..b598a3ee79 100644 --- a/docker/init.sh +++ b/docker/init.sh @@ -36,21 +36,7 @@ if [[ -n "$INVENTREE_PY_ENV" ]]; then # e.g docker-compose -f docker-compose.dev.yml run inventree-dev-server invoke install fi -# Wait for the InvenTree database to be ready -# cd ${INVENTREE_MNG_DIR} -# echo "InvenTree: Waiting for database connection" -# invoke wait && echo "InvenTree: Database connection successful" || { echo "InvenTree: Failed to connect to db due to errors, aborting"; exit 1; } -# sleep 5 - cd ${INVENTREE_HOME} -# We assume at this stage that the database is up and running -# echo "InvenTree: Checking database..." -# invoke check || exit 1 - -# Can be run as a cron job or directly to clean out expired sessions. -# cd ${INVENTREE_MNG_DIR} -# python3 manage.py clearsessions || exit 1 - # Launch the CMD *after* the ENTRYPOINT completes exec "$@" From d756579a067dbd6e7098cd43e438c3b61b43da17 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 13:02:36 +1000 Subject: [PATCH 50/59] Split production environment variables out into a .env file --- docker/docker-compose.yml | 37 ++++++++++++++----------------------- docker/prod-config.env | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 23 deletions(-) create mode 100644 docker/prod-config.env 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/prod-config.env b/docker/prod-config.env new file mode 100644 index 0000000000..93e3d123d6 --- /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_DEBUG_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_PASSWEORD=pgpassword From db477bceab768e7fcb2d7b90d94e91ccfe81dc19 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 14:47:34 +1000 Subject: [PATCH 51/59] typo fix --- docker/Dockerfile | 4 ++-- docker/prod-config.env | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9cfe88b2b5..2150b51558 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -51,8 +51,6 @@ LABEL org.label-schema.schema-version="1.0" \ # 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++ \ @@ -75,6 +73,8 @@ RUN apk add --no-cache git make bash \ # 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 diff --git a/docker/prod-config.env b/docker/prod-config.env index 93e3d123d6..bb922f4b32 100644 --- a/docker/prod-config.env +++ b/docker/prod-config.env @@ -13,4 +13,4 @@ INVENTREE_DB_NAME=inventree INVENTREE_DB_HOST=inventree-db INVENTREE_DB_PORT=5432 INVENTREE_DB_USER=pguser -INVENTREE_DB_PASSWEORD=pgpassword +INVENTREE_DB_PASSWORD=pgpassword From 41db0ff60d19fbfee6f85883f647bb766e5a9fb5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 14:58:16 +1000 Subject: [PATCH 52/59] Need to specify python3 --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 9088efb12f..7ebdd17480 100644 --- a/tasks.py +++ b/tasks.py @@ -188,7 +188,7 @@ 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]) From 79d7a9f922862bf9b55d520bd2a3d6777846d2e0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 15:16:22 +1000 Subject: [PATCH 53/59] fix typo in dockerfile --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2150b51558..1fadaa77cd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -46,7 +46,7 @@ LABEL org.label-schema.schema-version="1.0" \ org.label-schema.url="https://hub.docker.com/r/inventree/inventree" \ org.label-schema.vcs-url=${INVENTREE_GIT_REPO} \ org.label-schema.vcs-branch=${INVENTREE_GIT_BRANCH} \ - org.label-schema.vss-ref=${INVENTREE_GIT_TAG} + org.label-schema.vcs-ref=${INVENTREE_GIT_TAG} # Create user account RUN addgroup -S inventreegroup && adduser -S inventree -G inventreegroup From 2095d666778c42ff3418564c5fbb8c30be48fe5e Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 16:29:54 +1000 Subject: [PATCH 54/59] Fix entrypoint / cmd for production server --- InvenTree/InvenTree/tasks.py | 2 +- docker/Dockerfile | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 24631dc9e5..deb834c322 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -36,7 +36,7 @@ def schedule_task(taskname, **kwargs): # If this task is already scheduled, don't schedule it again # Instead, update the scheduling parameters if Schedule.objects.filter(func=taskname).exists(): - logger.info(f"Scheduled task '{taskname}' already exists - updating!") + logger.debug(f"Scheduled task '{taskname}' already exists - updating!") Schedule.objects.filter(func=taskname).update(**kwargs) else: diff --git a/docker/Dockerfile b/docker/Dockerfile index 1fadaa77cd..f117055a78 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -87,19 +87,20 @@ RUN if [ -n "${INVENTREE_GIT_TAG}" ] ; then cd ${INVENTREE_HOME} && git fetch -- RUN chown -R inventree:inventreegroup ${INVENTREE_HOME}/* -WORKDIR ${INVENTREE_HOME} - # Drop to the inventree user USER inventree # Install InvenTree packages RUN pip3 install --no-cache-dir --disable-pip-version-check -r ${INVENTREE_HOME}/requirements.txt +# Need to be running from within this directory +WORKDIR ${INVENTREE_MNG_DIR} + # Server init entrypoint -ENTRYPOINT ["/bin/bash", "./docker/init.sh"] +ENTRYPOINT ["/bin/bash", "../docker/init.sh"] # Launch the production server -CMD ["gunicorn -c ./docker/gunicorn.conf.py -b ${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT} InvenTree.wsgi"] +CMD ["gunicorn", "-c", "../docker/gunicorn.conf.py", "InvenTree.wsgi", "-b", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}", "--pythonpath", "${INVENTREE_MNG_DIR}"] FROM base as dev From eeac561b9b568f95f31e260f1d2293cfb5b4a98f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 17:07:23 +1000 Subject: [PATCH 55/59] typo fix --- docker/prod-config.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/prod-config.env b/docker/prod-config.env index bb922f4b32..50cf7a867b 100644 --- a/docker/prod-config.env +++ b/docker/prod-config.env @@ -4,7 +4,7 @@ # Ensure debug is false for a production setup INVENTREE_DEBUG=False -INVENTREE_DEBUG_LEVEL="WARNING" +INVENTREE_LOG_LEVEL="WARNING" # Database configuration # Note: The example setup is for a PostgreSQL database (change as required) From 52bdfe5465a9c60afbf723ab8d656a4100404fe5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 18 Aug 2021 20:52:14 +1000 Subject: [PATCH 56/59] Env interpolation doesn't seem to work in the CMD --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index f117055a78..2e6edd273d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -100,7 +100,7 @@ WORKDIR ${INVENTREE_MNG_DIR} ENTRYPOINT ["/bin/bash", "../docker/init.sh"] # Launch the production server -CMD ["gunicorn", "-c", "../docker/gunicorn.conf.py", "InvenTree.wsgi", "-b", "${INVENTREE_WEB_ADDR}:${INVENTREE_WEB_PORT}", "--pythonpath", "${INVENTREE_MNG_DIR}"] +CMD gunicorn -c ./docker/gunicorn.conf.py InvenTree.wsgi -b 0.0.0.0:8000 --chdir ./InvenTree FROM base as dev From 9ed2025021f5a04ff2fb041fddb77afbd0e8bef9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 Aug 2021 11:14:13 +1000 Subject: [PATCH 57/59] Add a TODO for future reference --- docker/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/Dockerfile b/docker/Dockerfile index 2e6edd273d..e4ebbc1b4b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -100,6 +100,8 @@ WORKDIR ${INVENTREE_MNG_DIR} ENTRYPOINT ["/bin/bash", "../docker/init.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 From f0415640d57c7a63ce1dbb1126d28aa06d221f8a Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 Aug 2021 16:34:57 +1000 Subject: [PATCH 58/59] Run periodic (daily) task to clear out expired sessions --- InvenTree/InvenTree/apps.py | 10 ++++++++++ InvenTree/InvenTree/tasks.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index feb46ee667..ee86b975dc 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -32,27 +32,37 @@ class InvenTreeConfig(AppConfig): logger.info("Starting background tasks...") + # Remove successful task results from the database InvenTree.tasks.schedule_task( 'InvenTree.tasks.delete_successful_tasks', schedule_type=Schedule.DAILY, ) + # Check for InvenTree updates InvenTree.tasks.schedule_task( 'InvenTree.tasks.check_for_updates', schedule_type=Schedule.DAILY ) + # Heartbeat to let the server know the background worker is running InvenTree.tasks.schedule_task( 'InvenTree.tasks.heartbeat', schedule_type=Schedule.MINUTES, minutes=15 ) + # Keep exchange rates up to date InvenTree.tasks.schedule_task( 'InvenTree.tasks.update_exchange_rates', schedule_type=Schedule.DAILY, ) + # Remove expired sessions + InvenTree.tasks.schedule_task( + 'InvenTree.tasks.delete_expired_sessions', + schedule_type=Schedule.DAILY, + ) + def update_exchange_rates(self): """ Update exchange rates each time the server is started, *if*: diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index deb834c322..5fb6960601 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -204,6 +204,25 @@ def check_for_updates(): ) +def delete_expired_sessions(): + """ + Remove any expired user sessions from the database + """ + + try: + from django.contrib.sessions.models import Session + + # Delete any sessions that expired more than a day ago + expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1)) + + if True or expired.count() > 0: + logger.info(f"Deleting {expired.count()} expired sessions.") + expired.delete() + + except AppRegistryNotReady: + logger.info("Could not perform 'delete_expired_sessions' - App registry not ready") + + def update_exchange_rates(): """ Update currency exchange rates From ec7392303d33fc84e51a7367b5b97749f44e7a93 Mon Sep 17 00:00:00 2001 From: eeintech Date: Thu, 19 Aug 2021 10:47:46 -0400 Subject: [PATCH 59/59] Fixed company templates --- .../company/templates/company/detail.html | 86 ++++++++++--------- .../templates/company/manufacturer_part.html | 4 +- InvenTree/part/templates/part/category.html | 2 +- InvenTree/part/templates/part/detail.html | 4 +- 4 files changed, 49 insertions(+), 47 deletions(-) diff --git a/InvenTree/company/templates/company/detail.html b/InvenTree/company/templates/company/detail.html index 884ec6e8de..806d7f6441 100644 --- a/InvenTree/company/templates/company/detail.html +++ b/InvenTree/company/templates/company/detail.html @@ -24,19 +24,17 @@ {% endif %}
    - + +
    @@ -59,27 +57,25 @@ {% if roles.purchase_order.change %}
    -
    +
    {% if roles.purchase_order.add %} - {% endif %} -
    - -
    +
    + + +
    @@ -87,7 +83,7 @@
    {% endif %} - +
    @@ -274,6 +270,10 @@ {% if company.is_manufacturer %} + function reloadManufacturerPartTable() { + $('#manufacturer-part-table').bootstrapTable('refresh'); + } + $("#manufacturer-part-create").click(function () { createManufacturerPart({ @@ -285,7 +285,7 @@ }); loadManufacturerPartTable( - "#part-table", + "#manufacturer-part-table", "{% url 'api-manufacturer-part-list' %}", { params: { @@ -296,20 +296,20 @@ } ); - linkButtonsToSelection($("#manufacturer-table"), ['#table-options']); + linkButtonsToSelection($("#manufacturer-part-table"), ['#manufacturer-table-options']); - $("#multi-part-delete").click(function() { - var selections = $("#part-table").bootstrapTable("getSelections"); + $("#multi-manufacturer-part-delete").click(function() { + var selections = $("#manufacturer-part-table").bootstrapTable("getSelections"); deleteManufacturerParts(selections, { onSuccess: function() { - $("#part-table").bootstrapTable("refresh"); + $("#manufacturer-part-table").bootstrapTable("refresh"); } }); }); - $("#multi-part-order").click(function() { - var selections = $("#part-table").bootstrapTable("getSelections"); + $("#multi-manufacturer-part-order").click(function() { + var selections = $("#manufacturer-part-table").bootstrapTable("getSelections"); var parts = []; @@ -353,9 +353,9 @@ } ); - {% endif %} + linkButtonsToSelection($("#supplier-part-table"), ['#supplier-table-options']); - $("#multi-part-delete").click(function() { + $("#multi-supplier-part-delete").click(function() { var selections = $("#supplier-part-table").bootstrapTable("getSelections"); var requests = []; @@ -379,8 +379,8 @@ ); }); - $("#multi-part-order").click(function() { - var selections = $("#part-table").bootstrapTable("getSelections"); + $("#multi-supplier-part-order").click(function() { + var selections = $("#supplier-part-table").bootstrapTable("getSelections"); var parts = []; @@ -395,6 +395,8 @@ }); }); + {% endif %} + attachNavCallbacks({ name: 'company', default: 'company-stock' diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html index 4623eb3a07..cc2dd68840 100644 --- a/InvenTree/company/templates/company/manufacturer_part.html +++ b/InvenTree/company/templates/company/manufacturer_part.html @@ -109,7 +109,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "New Supplier Part" %}
    - + @@ -133,7 +133,7 @@ src="{% static 'img/blank_image.png' %}" {% trans "New Parameter" %}
    - + diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 44e3ee0daa..21c5d0061e 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -132,7 +132,7 @@ {% endif %}
    - +