From efd464404566f58d0a7644f86ecc5cb55ba9109f Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 4 Aug 2021 07:55:58 +0200 Subject: [PATCH 01/46] translation information --- InvenTree/templates/InvenTree/settings/user.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index 140d2f6b86..3fcdc326bc 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -90,6 +90,8 @@ +

{% trans "Help the translation efforts!" %}

+

{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the InvenTree web application is community contributed via crowdin. Contributions are welcomed and encouraged.{% endblocktrans} %}

{% endblock %} \ No newline at end of file From a64ee23afc2aaf01ab5578dd571248ee5b6538b2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:16:11 +1000 Subject: [PATCH 02/46] Add more options for form rendering - "before" a field - "after" a field - pure "eye candy" field --- InvenTree/templates/js/translated/forms.js | 45 ++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 4801ec77eb..46b2b21a87 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -366,6 +366,10 @@ function constructFormBody(fields, options) { // TODO: Refactor the following code with Object.assign (see above) + // "before" and "after" renders + fields[field].before = field_options.before; + fields[field].after = field_options.after; + // Secondary modal options fields[field].secondary = field_options.secondary; @@ -560,10 +564,15 @@ function submitFormData(fields, options) { var has_files = false; // Extract values for each field - options.field_names.forEach(function(name) { + for (var idx = 0; idx < options.fields_names.length; idx++) { + + var name = options.field_names[idx]; var field = fields[name] || null; + // Ignore visual fields + if (field && field.type == 'candy') continue; + if (field) { var value = getFormFieldValue(name, field, options); @@ -593,7 +602,7 @@ function submitFormData(fields, options) { } else { console.log(`WARNING: Could not find field matching '${name}'`); } - }); + } var upload_func = inventreePut; @@ -1279,6 +1288,11 @@ function renderModelData(name, model, data, parameters, options) { */ function constructField(name, parameters, options) { + // Shortcut for simple visual fields + if (parameters.type == 'candy') { + return constructCandyInput(name, parameters, options); + } + var field_name = `id_${name}`; // Hidden inputs are rendered without label / help text / etc @@ -1292,7 +1306,14 @@ function constructField(name, parameters, options) { form_classes += ' has-error'; } - var html = `
`; + var html = ''; + + // Optional content to render before the field + if (parameters.before) { + html += parameters.before; + } + + html += `
`; // Add a label html += constructLabel(name, parameters); @@ -1352,6 +1373,10 @@ function constructField(name, parameters, options) { html += `
`; // controls html += `
`; // form-group + if (parameters.after) { + html += parameters.after; + } + return html; } @@ -1430,6 +1455,9 @@ function constructInput(name, parameters, options) { case 'date': func = constructDateInput; break; + case 'candy': + func = constructCandyInput; + break; default: // Unsupported field type! break; @@ -1658,6 +1686,17 @@ function constructDateInput(name, parameters, options) { } +/* + * Construct a "candy" field input + * No actual field data! + */ +function constructCandyInput(name, parameters, options) { + + return parameters.html; + +} + + /* * Construct a 'help text' div based on the field parameters * From 2bf3e3ab020a9030dd73d51c11ac426437d53a60 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:26:17 +1000 Subject: [PATCH 03/46] Function to construct part form fields --- InvenTree/part/api.py | 2 + InvenTree/templates/js/translated/forms.js | 2 +- InvenTree/templates/js/translated/part.js | 110 +++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a01b05034f..3b91d27c81 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -23,6 +23,7 @@ from djmoney.money import Money from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.exceptions import MissingRate +from decimal import Decimal from .models import Part, PartCategory, BomItem from .models import PartParameter, PartParameterTemplate @@ -30,6 +31,7 @@ from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartCategoryParameterTemplate +from stock.models import StockItem from common.models import InvenTreeSetting from build.models import Build diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 46b2b21a87..27337d97e7 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -564,7 +564,7 @@ function submitFormData(fields, options) { var has_files = false; // Extract values for each field - for (var idx = 0; idx < options.fields_names.length; idx++) { + for (var idx = 0; idx < options.field_names.length; idx++) { var name = options.field_names[idx]; diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index aaee9e47a0..fafdaa94e7 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -13,6 +13,116 @@ function yesNoLabel(value) { } } +// Construct fieldset for part forms +function partFields(options={}) { + + var fields = { + category: {}, + name: {}, + IPN: {}, + revision: {}, + description: {}, + variant_of: {}, + keywords: { + icon: 'fa-key', + }, + units: {}, + link: { + icon: 'fa-link', + }, + default_location: {}, + default_supplier: {}, + default_expiry: { + icon: 'fa-calendar-alt', + }, + minimum_stock: { + icon: 'fa-boxes', + }, + attributes: { + type: 'candy', + html: `

{% trans "Part Attributes" %}


` + }, + component: { + value: global_settings.PART_COMPONENT, + }, + assembly: { + value: global_settings.PART_ASSEMBLY, + }, + is_template: { + value: global_settings.PART_TEMPLATE, + }, + trackable: { + value: global_settings.PART_TRACKABLE, + }, + purchaseable: { + value: global_settings.PART_PURCHASEABLE, + }, + salable: { + value: global_settings.PART_SALABLE, + }, + virtual: { + value: global_settings.PART_VIRTUAL, + }, + }; + + // Pop expiry field + if (!global_settings.STOCK_ENABLE_EXPIRY) { + delete fields["default_expiry"]; + } + + // Additional fields when "creating" a new part + if (options.create) { + + // No supplier parts available yet + delete fields["default_supplier"]; + + fields.create = { + type: 'candy', + html: `

{% trans "Part Creation Options" %}


`, + }; + + if (global_settings.PART_CREATE_INITIAL) { + fields.initial_stock = { + type: 'decimal', + label: '{% trans "Initial Stock Quantity" %}', + help_text: '{% trans "Initialize part stock with specified quantity" %}', + }; + } + + fields.copy_category_parameters = { + type: 'boolean', + label: '{% trans "Copy Category Parameters" %}', + help_text: '{% trans "Copy parameter templates from selected part category" %}', + value: global_settings.PART_CATEGORY_PARAMETERS, + }; + } + + // Additional fields when "duplicating" a part + if (options.duplicate) { + + fields.duplicate = { + type: 'candy', + html: `

{% trans "Part Duplication Options" %}


`, + }; + + fields.copy_bom = { + type: 'boolean', + label: '{% trans "Copy BOM" %}', + help_text: '{% trans "Copy bill of materials from original part" %}', + value: global_settings.PART_COPY_BOM, + }; + + fields.copy_parameters = { + type: 'boolean', + label: '{% trans "Copy Parameters" %}', + help_text: '{% trans "Copy parameter data from original part" %}', + value: global_settings.PART_COPY_PARAMETERS, + }; + } + + return fields; +} + function categoryFields() { return { From b04f22fc53848981c1a581f73ae21312eb9a43a6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:27:16 +1000 Subject: [PATCH 04/46] CreatePart form now uses the API - Simplify the way category parameter templates are copied --- InvenTree/common/models.py | 3 +- InvenTree/part/api.py | 35 ++++- InvenTree/part/models.py | 48 +++--- InvenTree/part/templates/part/category.html | 38 ++--- InvenTree/part/test_views.py | 13 -- InvenTree/part/urls.py | 3 - InvenTree/part/views.py | 161 +------------------- 7 files changed, 74 insertions(+), 227 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 5d75a4dd74..839780d5b4 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -637,7 +637,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'PART_PURCHASEABLE': { 'name': _('Purchaseable'), 'description': _('Parts are purchaseable by default'), - 'default': False, + 'default': True, 'validator': bool, }, @@ -662,6 +662,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, + # TODO: Remove this setting in future, new API forms make this not useful 'PART_SHOW_QUANTITY_IN_FORMS': { 'name': _('Show Quantity in Forms'), 'description': _('Display available part quantity in some forms'), diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 3b91d27c81..88866ad58c 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -630,16 +630,47 @@ class PartList(generics.ListCreateAPIView): else: return Response(data) - def perform_create(self, serializer): + def create(self, request, *args, **kwargs): """ We wish to save the user who created this part! Note: Implementation copied from DRF class CreateModelMixin """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + part = serializer.save() part.creation_user = self.request.user - part.save() + + # Optionally copy templates from category or parent category + copy_templates = { + 'main': str2bool(request.data.get('copy_category_templates', False)), + 'parent': str2bool(request.data.get('copy_parent_templates', False)) + } + + part.save(**{'add_category_templates': copy_templates}) + + # Optionally create initial stock item + try: + initial_stock = Decimal(request.data.get('initial_stock', 0)) + + if initial_stock > 0 and part.default_location is not None: + + stock_item = StockItem( + part=part, + quantity=initial_stock, + location=part.default_location, + ) + + stock_item.save(user=request.user) + + except: + pass + + headers = self.get_success_headers(serializer.data) + + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def get_queryset(self, *args, **kwargs): diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 2dd5d3ad7f..b75edde9cc 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -409,7 +409,7 @@ class Part(MPTTModel): """ # Get category templates settings - add_category_templates = kwargs.pop('add_category_templates', None) + add_category_templates = kwargs.pop('add_category_templates', False) if self.pk: previous = Part.objects.get(pk=self.pk) @@ -437,39 +437,29 @@ class Part(MPTTModel): # Get part category category = self.category - if category and add_category_templates: - # Store templates added to part + if category is not None: + template_list = [] - # Create part parameters for selected category - category_templates = add_category_templates['main'] - if category_templates: + parent_categories = category.get_ancestors(include_self=True) + + for category in parent_categories: for template in category.get_parameter_templates(): - parameter = PartParameter.create(part=self, - template=template.parameter_template, - data=template.default_value, - save=True) - if parameter: + # Check that template wasn't already added + if template.parameter_template not in template_list: + template_list.append(template.parameter_template) - # Create part parameters for parent category - category_templates = add_category_templates['parent'] - if category_templates: - # Get parent categories - parent_categories = category.get_ancestors() - - for category in parent_categories: - for template in category.get_parameter_templates(): - # Check that template wasn't already added - if template.parameter_template not in template_list: - try: - PartParameter.create(part=self, - template=template.parameter_template, - data=template.default_value, - save=True) - except IntegrityError: - # PartParameter already exists - pass + try: + PartParameter.create( + part=self, + template=template.parameter_template, + data=template.default_value, + save=True + ) + except IntegrityError: + # PartParameter already exists + pass def __str__(self): return f"{self.full_name} - {self.description}" diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index 1c41092574..b149fd28ed 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -264,25 +264,25 @@ {% if roles.part.add %} $("#part-create").click(function() { - launchModalForm( - "{% url 'part-create' %}", - { - follow: true, - data: { - {% if category %} - category: {{ category.id }} - {% endif %} - }, - secondary: [ - { - field: 'default_location', - label: '{% trans "New Location" %}', - title: '{% trans "Create new Stock Location" %}', - url: "{% url 'stock-location-create' %}", - } - ] - } - ); + + var fields = partFields({ + create: true, + }); + + {% if category %} + fields.category.value = {{ category.pk }}; + {% endif %} + + constructForm('{% url "api-part-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Create Part" %}', + onSuccess: function(data) { + // Follow the new part + location.href = `/part/${data.pk}/`; + }, + }); + }); {% endif %} diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index 206d4dd56a..c555687183 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -158,19 +158,6 @@ class PartDetailTest(PartViewTestCase): class PartTests(PartViewTestCase): """ Tests for Part forms """ - def test_part_create(self): - """ Launch form to create a new part """ - response = self.client.get(reverse('part-create'), {'category': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # And again, with an invalid category - response = self.client.get(reverse('part-create'), {'category': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - # And again, with no category - response = self.client.get(reverse('part-create'), {'name': 'Test part'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - def test_part_duplicate(self): """ Launch form to duplicate part """ diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 52e9b929c1..0802a94f1a 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -81,9 +81,6 @@ category_urls = [ # URL list for part web interface part_urls = [ - # Create a new part - url(r'^new/?', views.PartCreate.as_view(), name='part-create'), - # Upload a part url(r'^import/', views.PartImport.as_view(), name='part-import'), url(r'^import-api/', views.PartImportAjax.as_view(), name='api-part-import'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index b35e752351..3e4b6c59d7 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -44,7 +44,7 @@ from common.files import FileManager from common.views import FileManagementFormView, FileManagementAjaxView from common.forms import UploadFileForm, MatchFieldForm -from stock.models import StockItem, StockLocation +from stock.models import StockLocation import common.settings as inventree_settings @@ -438,165 +438,6 @@ class PartDuplicate(AjaxCreateView): return initials -class PartCreate(AjaxCreateView): - """ View for creating a new Part object. - - Options for providing initial conditions: - - - Provide a category object as initial data - """ - model = Part - form_class = part_forms.EditPartForm - - ajax_form_title = _('Create New Part') - ajax_template_name = 'part/create_part.html' - - def get_data(self): - return { - 'success': _("Created new part"), - } - - def get_category_id(self): - return self.request.GET.get('category', None) - - def get_context_data(self, **kwargs): - """ Provide extra context information for the form to display: - - - Add category information (if provided) - """ - context = super(PartCreate, self).get_context_data(**kwargs) - - # Add category information to the page - cat_id = self.get_category_id() - - if cat_id: - try: - context['category'] = PartCategory.objects.get(pk=cat_id) - except (PartCategory.DoesNotExist, ValueError): - pass - - return context - - def get_form(self): - """ Create Form for making new Part object. - Remove the 'default_supplier' field as there are not yet any matching SupplierPart objects - """ - form = super(AjaxCreateView, self).get_form() - - # Hide the "default expiry" field if the feature is not enabled - if not inventree_settings.stock_expiry_enabled(): - form.fields['default_expiry'].widget = HiddenInput() - - # Hide the "initial stock amount" field if the feature is not enabled - if not InvenTreeSetting.get_setting('PART_CREATE_INITIAL'): - form.fields['initial_stock'].widget = HiddenInput() - - # Hide the default_supplier field (there are no matching supplier parts yet!) - form.fields['default_supplier'].widget = HiddenInput() - - # Display category templates widgets - form.fields['selected_category_templates'].widget = CheckboxInput() - form.fields['parent_category_templates'].widget = CheckboxInput() - - return form - - def post(self, request, *args, **kwargs): - - form = self.get_form() - - context = {} - - valid = form.is_valid() - - name = request.POST.get('name', None) - - if name: - matches = match_part_names(name) - - if len(matches) > 0: - - # Limit to the top 5 matches (to prevent clutter) - context['matches'] = matches[:5] - - # Enforce display of the checkbox - form.fields['confirm_creation'].widget = CheckboxInput() - - # Check if the user has checked the 'confirm_creation' input - confirmed = str2bool(request.POST.get('confirm_creation', False)) - - if not confirmed: - msg = _('Possible matches exist - confirm creation of new part') - form.add_error('confirm_creation', msg) - - form.pre_form_warning = msg - valid = False - - data = { - 'form_valid': valid - } - - if valid: - # Create the new Part - part = form.save(commit=False) - - # Record the user who created this part - part.creation_user = request.user - - # Store category templates settings - add_category_templates = { - 'main': form.cleaned_data['selected_category_templates'], - 'parent': form.cleaned_data['parent_category_templates'], - } - - # Save part and pass category template settings - part.save(**{'add_category_templates': add_category_templates}) - - # Add stock if set - init_stock = int(request.POST.get('initial_stock', 0)) - if init_stock: - stock = StockItem(part=part, - quantity=init_stock, - location=part.default_location) - stock.save() - - data['pk'] = part.pk - data['text'] = str(part) - - try: - data['url'] = part.get_absolute_url() - except AttributeError: - pass - - return self.renderJsonResponse(request, form, data, context=context) - - def get_initial(self): - """ Get initial data for the new Part object: - - - If a category is provided, pre-fill the Category field - """ - - initials = super(PartCreate, self).get_initial() - - if self.get_category_id(): - try: - category = PartCategory.objects.get(pk=self.get_category_id()) - initials['category'] = category - initials['keywords'] = category.default_keywords - except (PartCategory.DoesNotExist, ValueError): - pass - - # Allow initial data to be passed through as arguments - for label in ['name', 'IPN', 'description', 'revision', 'keywords']: - if label in self.request.GET: - initials[label] = self.request.GET.get(label) - - # Automatically create part parameters from category templates - initials['selected_category_templates'] = str2bool(InvenTreeSetting.get_setting('PART_CATEGORY_PARAMETERS', False)) - initials['parent_category_templates'] = initials['selected_category_templates'] - - return initials - - class PartImport(FileManagementFormView): ''' Part: Upload file, match to fields and import parts(using multi-Step form) ''' permission_required = 'part.add' From 1fafaf857720ef578392e0444b963b1c5abd1208 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:29:39 +1000 Subject: [PATCH 05/46] Refactor partfields function (was essentially duplicated) --- InvenTree/templates/js/translated/part.js | 81 +++-------------------- 1 file changed, 8 insertions(+), 73 deletions(-) diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index fafdaa94e7..988481d77c 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -65,6 +65,11 @@ function partFields(options={}) { }, }; + // If editing a part, we can set the "active" status + if (options.edit) { + fields.active = {}; + } + // Pop expiry field if (!global_settings.STOCK_ENABLE_EXPIRY) { delete fields["default_expiry"]; @@ -159,79 +164,9 @@ function editPart(pk, options={}) { var url = `/api/part/${pk}/`; - var fields = { - category: { - /* - secondary: { - label: '{% trans "New Category" %}', - title: '{% trans "Create New Part Category" %}', - api_url: '{% url "api-part-category-list" %}', - method: 'POST', - fields: { - name: {}, - description: {}, - parent: { - secondary: { - title: '{% trans "New Parent" %}', - api_url: '{% url "api-part-category-list" %}', - method: 'POST', - fields: { - name: {}, - description: {}, - parent: {}, - } - } - }, - } - }, - */ - }, - name: { - placeholder: 'part name', - }, - IPN: {}, - description: {}, - revision: {}, - keywords: { - icon: 'fa-key', - }, - variant_of: {}, - link: { - icon: 'fa-link', - }, - default_location: { - /* - secondary: { - label: '{% trans "New Location" %}', - title: '{% trans "Create new stock location" %}', - }, - */ - }, - default_supplier: { - filters: { - part: pk, - part_detail: true, - manufacturer_detail: true, - supplier_detail: true, - }, - /* - secondary: { - label: '{% trans "New Supplier Part" %}', - title: '{% trans "Create new supplier part" %}', - } - */ - }, - units: {}, - minimum_stock: {}, - virtual: {}, - is_template: {}, - assembly: {}, - component: {}, - trackable: {}, - purchaseable: {}, - salable: {}, - active: {}, - }; + var fields = partFields({ + edit: true + }); constructForm(url, { fields: fields, From 408ff639ddb18e6c0d539aed7754f759dcea1bb3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 4 Aug 2021 23:48:21 +1000 Subject: [PATCH 06/46] Adds ability to pre-fill a form with a complete dataset --- InvenTree/templates/js/translated/forms.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 27337d97e7..3b55802f38 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -240,6 +240,7 @@ function constructDeleteForm(fields, options) { * - hidden: Set to true to hide the field * - icon: font-awesome icon to display before the field * - prefix: Custom HTML prefix to display before the field + * - data: map of data to fill out field values with * - focus: Name of field to focus on when modal is displayed * - preventClose: Set to true to prevent form from closing on success * - onSuccess: callback function when form action is successful @@ -263,6 +264,11 @@ function constructForm(url, options) { // Default HTTP method options.method = options.method || 'PATCH'; + // Construct an "empty" data object if not provided + if (!options.data) { + options.data = {}; + } + // Request OPTIONS endpoint from the API getApiEndpointOptions(url, function(OPTIONS) { @@ -346,10 +352,19 @@ function constructFormBody(fields, options) { // otherwise *all* fields will be displayed var displayed_fields = options.fields || fields; + // Handle initial data overrides + if (options.data) { + for (const field in options.data) { + + if (field in fields) { + fields[field].value = options.data[field]; + } + } + } + // Provide each field object with its own name for(field in fields) { fields[field].name = field; - // If any "instance_filters" are defined for the endpoint, copy them across (overwrite) if (fields[field].instance_filters) { From 2cb0b448b77b2dd429167c38f4d4ec60e37a1871 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:15:55 +1000 Subject: [PATCH 07/46] Fix error message styles for API errors - django ValidationError uses "__all__" key for non_field_errors - whyyyyyyyyyyyy --- InvenTree/InvenTree/serializers.py | 16 ++++++++++++--- InvenTree/templates/js/translated/part.js | 25 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 58d33697b7..baf08e112b 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -85,8 +85,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): """ def __init__(self, instance=None, data=empty, **kwargs): - - # self.instance = instance + """ + Custom __init__ routine to ensure that *default* values (as specified in the ORM) + are used by the DRF serializers, *if* the values are not provided by the user. + """ # If instance is None, we are creating a new instance if instance is None and data is not empty: @@ -193,7 +195,15 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): try: instance.full_clean() except (ValidationError, DjangoValidationError) as exc: - raise ValidationError(detail=serializers.as_serializer_error(exc)) + + data = exc.message_dict + + # Change '__all__' key (django style) to 'non_field_errors' (DRF style) + if '__all__' in data: + data['non_field_errors'] = data['__all__'] + del data['__all__'] + + raise ValidationError(data) return data diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 988481d77c..f8b410c9c0 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -173,7 +173,32 @@ function editPart(pk, options={}) { title: '{% trans "Edit Part" %}', reload: true, }); +} + +function duplicatePart(pk, options={}) { + + // First we need all the part information + inventreeGet(`/api/part/${pk}/`, {}, { + + success: function(response) { + + var fields = partFields({ + duplicate: true + }); + + constructForm('{% url "api-part-list" %}', { + method: 'POST', + fields: fields, + title: '{% trans "Duplicate Part" %}', + data: response, + onSuccess: function(data) { + // Follow the new part + location.href = `/part/${data.pk}/`; + } + }); + } + }); } From 0e8fb6a5ad6df073770c243f9af96336f920365f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:16:42 +1000 Subject: [PATCH 08/46] Refactored DuplicatePart form - API endpoint now takes care of duplication of other data --- InvenTree/part/api.py | 28 +++++ InvenTree/part/templates/part/part_base.html | 7 +- InvenTree/part/urls.py | 1 - InvenTree/part/views.py | 124 ------------------- InvenTree/templates/js/translated/part.js | 15 ++- 5 files changed, 43 insertions(+), 132 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 88866ad58c..789ba9b9b7 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -651,6 +651,34 @@ class PartList(generics.ListCreateAPIView): part.save(**{'add_category_templates': copy_templates}) + # Optionally copy data from another part (e.g. when duplicating) + copy_from = request.data.get('copy_from', None) + + if copy_from is not None: + + try: + original = Part.objects.get(pk=copy_from) + + copy_bom = str2bool(request.data.get('copy_bom', False)) + copy_parameters = str2bool(request.data.get('copy_parameters', False)) + copy_image = str2bool(request.data.get('copy_image', True)) + + # Copy image? + if copy_image: + part.image = original.image + part.save() + + # Copy BOM? + if copy_bom: + part.copy_bom_from(original) + + # Copy parameter data? + if copy_parameters: + part.copy_parameters_from(original) + + except (ValueError, Part.DoesNotExist): + pass + # Optionally create initial stock item try: initial_stock = Decimal(request.data.get('initial_stock', 0)) diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index ec637412a8..0c29f1c26b 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -486,12 +486,7 @@ {% if roles.part.add %} $("#part-duplicate").click(function() { - launchModalForm( - "{% url 'part-duplicate' part.id %}", - { - follow: true, - } - ); + duplicatePart({{ part.pk }}); }); {% endif %} diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 0802a94f1a..53d28f7ccb 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -40,7 +40,6 @@ part_detail_urls = [ url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), - url(r'^duplicate/', views.PartDuplicate.as_view(), name='part-duplicate'), url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'), url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 3e4b6c59d7..c4ae2aee77 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -314,130 +314,6 @@ class MakePartVariant(AjaxCreateView): return initials -class PartDuplicate(AjaxCreateView): - """ View for duplicating an existing Part object. - - - Part is provided in the URL '/part//copy/' - - Option for 'deep-copy' which will duplicate all BOM items (default = True) - """ - - model = Part - form_class = part_forms.EditPartForm - - ajax_form_title = _("Duplicate Part") - ajax_template_name = "part/copy_part.html" - - def get_data(self): - return { - 'success': _('Copied part') - } - - def get_part_to_copy(self): - try: - return Part.objects.get(id=self.kwargs['pk']) - except (Part.DoesNotExist, ValueError): - return None - - def get_context_data(self): - return { - 'part': self.get_part_to_copy() - } - - def get_form(self): - form = super(AjaxCreateView, self).get_form() - - # Force display of the 'bom_copy' widget - form.fields['bom_copy'].widget = CheckboxInput() - - # Force display of the 'parameters_copy' widget - form.fields['parameters_copy'].widget = CheckboxInput() - - return form - - def post(self, request, *args, **kwargs): - """ Capture the POST request for part duplication - - - If the bom_copy object is set, copy all the BOM items too! - - If the parameters_copy object is set, copy all the parameters too! - """ - - form = self.get_form() - - context = self.get_context_data() - - valid = form.is_valid() - - name = request.POST.get('name', None) - - if name: - matches = match_part_names(name) - - if len(matches) > 0: - # Display the first five closest matches - context['matches'] = matches[:5] - - # Enforce display of the checkbox - form.fields['confirm_creation'].widget = CheckboxInput() - - # Check if the user has checked the 'confirm_creation' input - confirmed = str2bool(request.POST.get('confirm_creation', False)) - - if not confirmed: - msg = _('Possible matches exist - confirm creation of new part') - form.add_error('confirm_creation', msg) - form.pre_form_warning = msg - valid = False - - data = { - 'form_valid': valid - } - - if valid: - # Create the new Part - part = form.save(commit=False) - - part.creation_user = request.user - part.save() - - data['pk'] = part.pk - data['text'] = str(part) - - bom_copy = str2bool(request.POST.get('bom_copy', False)) - parameters_copy = str2bool(request.POST.get('parameters_copy', False)) - - original = self.get_part_to_copy() - - if original: - part.deep_copy(original, bom=bom_copy, parameters=parameters_copy) - - try: - data['url'] = part.get_absolute_url() - except AttributeError: - pass - - if valid: - pass - - return self.renderJsonResponse(request, form, data, context=context) - - def get_initial(self): - """ Get initial data based on the Part to be copied from. - """ - - part = self.get_part_to_copy() - - if part: - initials = model_to_dict(part) - else: - initials = super(AjaxCreateView, self).get_initial() - - initials['bom_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_BOM', True)) - - initials['parameters_copy'] = str2bool(InvenTreeSetting.get_setting('PART_COPY_PARAMETERS', True)) - - return initials - - class PartImport(FileManagementFormView): ''' Part: Upload file, match to fields and import parts(using multi-Step form) ''' permission_required = 'part.add' diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index f8b410c9c0..a1d40f7bf4 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -110,6 +110,19 @@ function partFields(options={}) { html: `

{% trans "Part Duplication Options" %}


`, }; + fields.copy_from = { + type: 'integer', + hidden: true, + value: options.duplicate, + }, + + fields.copy_image = { + type: 'boolean', + label: '{% trans "Copy Image" %}', + help_text: '{% trans "Copy image from original part" %}', + value: true, + }, + fields.copy_bom = { type: 'boolean', label: '{% trans "Copy BOM" %}', @@ -184,7 +197,7 @@ function duplicatePart(pk, options={}) { success: function(response) { var fields = partFields({ - duplicate: true + duplicate: pk, }); constructForm('{% url "api-part-list" %}', { From aa4ed9feb07c1f32ab45e99f3d0de8e6aa2870ee Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:24:38 +1000 Subject: [PATCH 09/46] Refactor MakeVariant form - Now is essentially identical to the DuplicatePart form - Uses the API form structure --- InvenTree/part/forms.py | 76 --------------------- InvenTree/part/templates/part/detail.html | 7 +- InvenTree/part/test_views.py | 19 ------ InvenTree/part/urls.py | 2 +- InvenTree/part/views.py | 81 ----------------------- InvenTree/templates/js/translated/part.js | 12 +++- 6 files changed, 15 insertions(+), 182 deletions(-) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 1fc2848440..f5d7d39266 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -177,82 +177,6 @@ class SetPartCategoryForm(forms.Form): part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category')) -class EditPartForm(HelperForm): - """ - Form for editing a Part object. - """ - - field_prefix = { - 'keywords': 'fa-key', - 'link': 'fa-link', - 'IPN': 'fa-hashtag', - 'default_expiry': 'fa-stopwatch', - } - - bom_copy = forms.BooleanField(required=False, - initial=True, - help_text=_("Duplicate all BOM data for this part"), - label=_('Copy BOM'), - widget=forms.HiddenInput()) - - parameters_copy = forms.BooleanField(required=False, - initial=True, - help_text=_("Duplicate all parameter data for this part"), - label=_('Copy Parameters'), - widget=forms.HiddenInput()) - - confirm_creation = forms.BooleanField(required=False, - initial=False, - help_text=_('Confirm part creation'), - widget=forms.HiddenInput()) - - selected_category_templates = forms.BooleanField(required=False, - initial=False, - label=_('Include category parameter templates'), - widget=forms.HiddenInput()) - - parent_category_templates = forms.BooleanField(required=False, - initial=False, - label=_('Include parent categories parameter templates'), - widget=forms.HiddenInput()) - - initial_stock = forms.IntegerField(required=False, - initial=0, - label=_('Initial stock amount'), - help_text=_('Create stock for this part')) - - class Meta: - model = Part - fields = [ - 'confirm_creation', - 'category', - 'selected_category_templates', - 'parent_category_templates', - 'name', - 'IPN', - 'description', - 'revision', - 'bom_copy', - 'parameters_copy', - 'keywords', - 'variant_of', - 'link', - 'default_location', - 'default_supplier', - 'default_expiry', - 'units', - 'minimum_stock', - 'initial_stock', - 'component', - 'assembly', - 'is_template', - 'trackable', - 'purchaseable', - 'salable', - 'virtual', - ] - - class EditPartParameterTemplateForm(HelperForm): """ Form for editing a PartParameterTemplate object """ diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 267b880d49..165ea37e66 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -525,10 +525,11 @@ loadPartVariantTable($('#variants-table'), {{ part.pk }}); $('#new-variant').click(function() { - launchModalForm( - "{% url 'make-part-variant' part.id %}", + + duplicatePart( + {{ part.pk}}, { - follow: true, + variant: true, } ); }); diff --git a/InvenTree/part/test_views.py b/InvenTree/part/test_views.py index c555687183..5f2a9b1583 100644 --- a/InvenTree/part/test_views.py +++ b/InvenTree/part/test_views.py @@ -155,25 +155,6 @@ class PartDetailTest(PartViewTestCase): self.assertIn('streaming_content', dir(response)) -class PartTests(PartViewTestCase): - """ Tests for Part forms """ - - def test_part_duplicate(self): - """ Launch form to duplicate part """ - - # First try with an invalid part - response = self.client.get(reverse('part-duplicate', args=(9999,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - response = self.client.get(reverse('part-duplicate', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - def test_make_variant(self): - - response = self.client.get(reverse('make-part-variant', args=(1,)), HTTP_X_REQUESTED_WITH='XMLHttpRequest') - self.assertEqual(response.status_code, 200) - - class PartRelatedTests(PartViewTestCase): def test_valid_create(self): diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 53d28f7ccb..13fc6f7c16 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -40,7 +40,7 @@ part_detail_urls = [ url(r'^bom-export/?', views.BomExport.as_view(), name='bom-export'), url(r'^bom-download/?', views.BomDownload.as_view(), name='bom-download'), url(r'^validate-bom/', views.BomValidate.as_view(), name='bom-validate'), - url(r'^make-variant/', views.MakePartVariant.as_view(), name='make-part-variant'), + url(r'^pricing/', views.PartPricing.as_view(), name='part-pricing'), url(r'^bom-upload/?', views.BomUpload.as_view(), name='upload-bom'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index c4ae2aee77..e805e8f260 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -233,87 +233,6 @@ class PartSetCategory(AjaxUpdateView): return ctx -class MakePartVariant(AjaxCreateView): - """ View for creating a new variant based on an existing template Part - - - Part is provided in the URL '/part//make_variant/' - - Automatically copy relevent data (BOM, etc, etc) - - """ - - model = Part - form_class = part_forms.EditPartForm - - ajax_form_title = _('Create Variant') - ajax_template_name = 'part/variant_part.html' - - def get_part_template(self): - return get_object_or_404(Part, id=self.kwargs['pk']) - - def get_context_data(self): - return { - 'part': self.get_part_template(), - } - - def get_form(self): - form = super(AjaxCreateView, self).get_form() - - # Hide some variant-related fields - # form.fields['variant_of'].widget = HiddenInput() - - # Force display of the 'bom_copy' widget - form.fields['bom_copy'].widget = CheckboxInput() - - # Force display of the 'parameters_copy' widget - form.fields['parameters_copy'].widget = CheckboxInput() - - return form - - def post(self, request, *args, **kwargs): - - form = self.get_form() - context = self.get_context_data() - part_template = self.get_part_template() - - valid = form.is_valid() - - data = { - 'form_valid': valid, - } - - if valid: - # Create the new part variant - part = form.save(commit=False) - part.variant_of = part_template - part.is_template = False - - part.save() - - data['pk'] = part.pk - data['text'] = str(part) - data['url'] = part.get_absolute_url() - - bom_copy = str2bool(request.POST.get('bom_copy', False)) - parameters_copy = str2bool(request.POST.get('parameters_copy', False)) - - # Copy relevent information from the template part - part.deep_copy(part_template, bom=bom_copy, parameters=parameters_copy) - - return self.renderJsonResponse(request, form, data, context=context) - - def get_initial(self): - - part_template = self.get_part_template() - - initials = model_to_dict(part_template) - initials['is_template'] = False - initials['variant_of'] = part_template - initials['bom_copy'] = InvenTreeSetting.get_setting('PART_COPY_BOM') - initials['parameters_copy'] = InvenTreeSetting.get_setting('PART_COPY_PARAMETERS') - - return initials - - class PartImport(FileManagementFormView): ''' Part: Upload file, match to fields and import parts(using multi-Step form) ''' permission_required = 'part.add' diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index a1d40f7bf4..3def7abdad 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -189,22 +189,30 @@ function editPart(pk, options={}) { } +// Launch form to duplicate a part function duplicatePart(pk, options={}) { // First we need all the part information inventreeGet(`/api/part/${pk}/`, {}, { - success: function(response) { + success: function(data) { var fields = partFields({ duplicate: pk, }); + + // If we are making a "variant" part + if (options.variant) { + + // Override the "variant_of" field + data.variant_of = pk; + } constructForm('{% url "api-part-list" %}', { method: 'POST', fields: fields, title: '{% trans "Duplicate Part" %}', - data: response, + data: data, onSuccess: function(data) { // Follow the new part location.href = `/part/${data.pk}/`; From dd78464a749c51d44f588f320ce7ad9f196a7c59 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:25:47 +1000 Subject: [PATCH 10/46] remove unused function --- InvenTree/part/models.py | 51 ------------------------------------- InvenTree/part/test_part.py | 8 +----- InvenTree/part/views.py | 4 +-- 3 files changed, 2 insertions(+), 61 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index b75edde9cc..89e92115ca 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -235,57 +235,6 @@ def rename_part_image(instance, filename): return os.path.join(base, fname) -def match_part_names(match, threshold=80, reverse=True, compare_length=False): - """ Return a list of parts whose name matches the search term using fuzzy search. - - Args: - match: Term to match against - threshold: Match percentage that must be exceeded (default = 65) - reverse: Ordering for search results (default = True - highest match is first) - compare_length: Include string length checks - - Returns: - A sorted dict where each element contains the following key:value pairs: - - 'part' : The matched part - - 'ratio' : The matched ratio - """ - - match = str(match).strip().lower() - - if len(match) == 0: - return [] - - parts = Part.objects.all() - - matches = [] - - for part in parts: - compare = str(part.name).strip().lower() - - if len(compare) == 0: - continue - - ratio = fuzz.partial_token_sort_ratio(compare, match) - - if compare_length: - # Also employ primitive length comparison - # TODO - Improve this somewhat... - l_min = min(len(match), len(compare)) - l_max = max(len(match), len(compare)) - - ratio *= (l_min / l_max) - - if ratio >= threshold: - matches.append({ - 'part': part, - 'ratio': round(ratio, 1) - }) - - matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse) - - return matches - - class PartManager(TreeManager): """ Defines a custom object manager for the Part model. diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index e30c80549f..b32b30a22e 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -12,7 +12,7 @@ from django.core.exceptions import ValidationError import os from .models import Part, PartCategory, PartTestTemplate -from .models import rename_part_image, match_part_names +from .models import rename_part_image from .templatetags import inventree_extras import part.settings @@ -163,12 +163,6 @@ class PartTest(TestCase): def test_copy(self): self.r2.deep_copy(self.r1, image=True, bom=True) - def test_match_names(self): - - matches = match_part_names('M2x5 LPHS') - - self.assertTrue(len(matches) > 0) - def test_sell_pricing(self): # check that the sell pricebreaks were loaded self.assertTrue(self.r1.has_price_breaks) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index e805e8f260..0e06678694 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -14,8 +14,7 @@ from django.shortcuts import HttpResponseRedirect from django.utils.translation import gettext_lazy as _ from django.urls import reverse from django.views.generic import DetailView, ListView -from django.forms.models import model_to_dict -from django.forms import HiddenInput, CheckboxInput +from django.forms import HiddenInput from django.conf import settings from django.contrib import messages @@ -35,7 +34,6 @@ from .models import PartCategory, Part, PartRelated from .models import PartParameterTemplate from .models import PartCategoryParameterTemplate from .models import BomItem -from .models import match_part_names from .models import PartSellPriceBreak, PartInternalPriceBreak from common.models import InvenTreeSetting From aaf394ca7a1698ea6839fb7511485a1f85480f9f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:26:21 +1000 Subject: [PATCH 11/46] PEP fixes --- InvenTree/part/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 89e92115ca..28fd3ce793 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -34,7 +34,6 @@ from stdimage.models import StdImageField from decimal import Decimal, InvalidOperation from datetime import datetime -from rapidfuzz import fuzz import hashlib from InvenTree import helpers From 6acff2a26e8f6a578207d16ef41b61bf1b04f415 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:40:02 +1000 Subject: [PATCH 12/46] Fixes unit test --- InvenTree/part/test_part.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index b32b30a22e..1e831601a4 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -287,7 +287,7 @@ class PartSettingsTest(TestCase): part = self.make_part() self.assertTrue(part.component) - self.assertFalse(part.purchaseable) + self.assertTrue(part.purchaseable) self.assertFalse(part.salable) self.assertFalse(part.trackable) From 655e5692e98d55c12dd122d8513ef3346199e9da Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 00:58:07 +1000 Subject: [PATCH 13/46] More unit test fixes --- InvenTree/part/test_part.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 1e831601a4..1bd9fdf87d 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -275,7 +275,7 @@ class PartSettingsTest(TestCase): """ self.assertTrue(part.settings.part_component_default()) - self.assertFalse(part.settings.part_purchaseable_default()) + self.assertTrue(part.settings.part_purchaseable_default()) self.assertFalse(part.settings.part_salable_default()) self.assertFalse(part.settings.part_trackable_default()) From c7712d4235ef06ade7baf29f40bfdc77706dc859 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 5 Aug 2021 01:13:48 +1000 Subject: [PATCH 14/46] even more unit tests --- InvenTree/part/test_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 7700c5c61f..bbd73b73e0 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -434,8 +434,8 @@ class PartAPITest(InvenTreeAPITestCase): self.assertTrue(data['active']) self.assertFalse(data['virtual']) - # By default, parts are not purchaseable - self.assertFalse(data['purchaseable']) + # By default, parts are purchaseable + self.assertTrue(data['purchaseable']) # Set the default 'purchaseable' status to True InvenTreeSetting.set_setting( From 3ecb1e6577c65d10775b975b0a1900bb5784f7c9 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 4 Aug 2021 19:44:01 +0200 Subject: [PATCH 15/46] cleaner structure --- .../templates/InvenTree/settings/user.html | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index 3fcdc326bc..aa3f7f079c 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -71,27 +71,31 @@
-
- {% csrf_token %} - -
- -
-
- -
-
-

{% trans "Help the translation efforts!" %}

-

{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the InvenTree web application is community contributed via crowdin. Contributions are welcomed and encouraged.{% endblocktrans} %}

+
+
+ {% csrf_token %} + +
+ +
+
+ +
+
+
+
+

{% trans "Help the translation efforts!" %}

+

{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the InvenTree web application is community contributed via crowdin. Contributions are welcomed and encouraged.{% endblocktrans %}

+
{% endblock %} \ No newline at end of file From c0ccb8f588634f07a94209eea2c1dc58a9002c61 Mon Sep 17 00:00:00 2001 From: eeintech Date: Wed, 4 Aug 2021 17:11:35 -0400 Subject: [PATCH 16/46] Fixed typo for build responsible column header --- InvenTree/templates/js/translated/build.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index 4b8cd47eb5..26f3876af3 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -927,7 +927,7 @@ function loadBuildTable(table, options) { }, { field: 'responsible', - title: '{% trans "Resposible" %}', + title: '{% trans "Responsible" %}', sortable: true, formatter: function(value, row, index, field) { if (value) From d6672372a51541fd77913f9ad3d5962456bbd898 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 5 Aug 2021 00:44:02 +0200 Subject: [PATCH 17/46] script to save the locale stats --- .gitignore | 5 ++++- InvenTree/script/translation_stats.py | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c53a837e24..5610fc4304 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,7 @@ secret_key.txt htmlcov/ # Development files -dev/ \ No newline at end of file +dev/ + +# Locale stats file +locale_stats.json diff --git a/InvenTree/script/translation_stats.py b/InvenTree/script/translation_stats.py index f47a21f168..c6126f2944 100644 --- a/InvenTree/script/translation_stats.py +++ b/InvenTree/script/translation_stats.py @@ -3,6 +3,7 @@ This script calculates translation coverage for various languages """ import os +import json def calculate_coverage(filename): @@ -36,8 +37,10 @@ if __name__ == '__main__': MY_DIR = os.path.dirname(os.path.realpath(__file__)) LC_DIR = os.path.abspath(os.path.join(MY_DIR, '..', 'locale')) + STAT_FILE = os.path.abspath(os.path.join(MY_DIR, '..', 'InvenTree/locale_stats.json')) locales = {} + locales_perc = {} print("InvenTree translation coverage:") @@ -64,5 +67,10 @@ if __name__ == '__main__': percentage = 0 print(f"| {locale.ljust(4, ' ')} : {str(percentage).rjust(4, ' ')}% |") + locales_perc[locale] = percentage print("-" * 16) + + # write locale stats + with open(STAT_FILE, 'w') as target: + json.dump(locales_perc, target) From 58f2dce18d3b97997d41d04849d303503d54a0f6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 5 Aug 2021 01:23:11 +0200 Subject: [PATCH 18/46] show translation level in ui --- InvenTree/InvenTree/views.py | 10 ++++++++++ InvenTree/templates/InvenTree/settings/user.html | 13 ++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 9749fd60d0..69deb8fd97 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -7,12 +7,15 @@ as JSON objects and passing them to modal forms (using jQuery / bootstrap). # -*- coding: utf-8 -*- from __future__ import unicode_literals +import os +import json from django.utils.translation import gettext_lazy as _ from django.template.loader import render_to_string from django.http import HttpResponse, JsonResponse, HttpResponseRedirect from django.urls import reverse_lazy from django.shortcuts import redirect +from django.conf import settings from django.contrib.auth.mixins import PermissionRequiredMixin @@ -802,6 +805,13 @@ class SettingsView(TemplateView): except: ctx["rates_updated"] = None + # load locale stats + STAT_FILE = os.path.abspath(os.path.join(settings.BASE_DIR, 'InvenTree/locale_stats.json')) + try: + ctx["locale_stats"] = json.load(open(STAT_FILE, 'r')) + except: + ctx["locale_stats"] = {} + return ctx diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index aa3f7f079c..922e9ebc79 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -81,8 +81,15 @@ {% get_available_languages as LANGUAGES %} {% get_language_info_list for LANGUAGES as languages %} {% for language in languages %} - {% endfor %} @@ -92,7 +99,7 @@ -
+

{% trans "Help the translation efforts!" %}

{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the InvenTree web application is community contributed via crowdin. Contributions are welcomed and encouraged.{% endblocktrans %}

From d6c6cb96ba10c82cafc55eb63bbe48e64bce3946 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 5 Aug 2021 01:24:49 +0200 Subject: [PATCH 19/46] make keyvalue non-existing key tolerant --- InvenTree/part/templatetags/inventree_extras.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 5ebc939305..dce3d248e5 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -269,7 +269,7 @@ def keyvalue(dict, key): usage: {% mydict|keyvalue:mykey %} """ - return dict[key] + return dict.get(key) @register.simple_tag() From fa6daeb679a1beaea293dc9b90ce22031f0c3163 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 5 Aug 2021 09:47:15 +1000 Subject: [PATCH 20/46] Pin weasyprint version to 52.5 --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index abcf2cb098..839237c6a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,8 @@ coverage==5.3 # Unit test coverage coveralls==2.1.2 # Coveralls linking (for Travis) rapidfuzz==0.7.6 # Fuzzy string matching django-stdimage==5.1.1 # Advanced ImageField management -django-weasyprint==1.0.1 # HTML PDF export +weasyprint==52.5 # PDF generation library (Note: in the future need to update to 53) +django-weasyprint==1.0.1 # django weasyprint integration django-debug-toolbar==2.2 # Debug / profiling toolbar django-admin-shell==0.1.2 # Python shell for the admin interface py-moneyed==0.8.0 # Specific version requirement for py-moneyed From 00ffab472c7fbfe368a463ccfa4d99fd606d5674 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 5 Aug 2021 10:44:47 +1000 Subject: [PATCH 21/46] Fix for build report template --- .../report/templates/report/inventree_build_order_base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/report/templates/report/inventree_build_order_base.html b/InvenTree/report/templates/report/inventree_build_order_base.html index 5a0cbde93c..2d2d2766bb 100644 --- a/InvenTree/report/templates/report/inventree_build_order_base.html +++ b/InvenTree/report/templates/report/inventree_build_order_base.html @@ -79,7 +79,7 @@ content: "v{{report_revision}} - {{ date.isoformat }}"; {% block header_content %} - +

From 6234581fab18e247f01265db17264757caf73695 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 7 Aug 2021 11:16:37 +1000 Subject: [PATCH 22/46] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a23ab608e..7ace518175 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ However, powerful business logic works in the background to ensure that stock tr InvenTree is [available via Docker](https://hub.docker.com/r/inventree/inventree). Read the [docker guide](https://inventree.readthedocs.io/en/latest/start/docker/) for full details. -# Companion App +# Mobile App InvenTree is supported by a [companion mobile app](https://inventree.readthedocs.io/en/latest/app/app/) which allows users access to stock control information and functionality. From 2e5d5bcc840da18f14871cff71b895efbb4e8735 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 7 Aug 2021 20:50:32 +1000 Subject: [PATCH 23/46] Fix part settings page --- .../templates/InvenTree/settings/part.html | 77 ------------------- .../InvenTree/settings/settings.html | 73 ++++++++++++++++++ 2 files changed, 73 insertions(+), 77 deletions(-) diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index 3fa20820b1..14a18a1aa5 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -68,80 +68,3 @@ {% endblock %} - -{% block js_ready %} -{{ block.super }} - - $("#param-table").inventreeTable({ - url: "{% url 'api-part-parameter-template-list' %}", - queryParams: { - ordering: 'name', - }, - formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; }, - columns: [ - { - field: 'pk', - title: 'ID', - visible: false, - switchable: false, - }, - { - field: 'name', - title: 'Name', - sortable: 'true', - }, - { - field: 'units', - title: 'Units', - sortable: 'true', - }, - { - formatter: function(value, row, index, field) { - var bEdit = ""; - var bDel = ""; - - var html = "
" + bEdit + bDel + "
"; - - return html; - } - } - ] - }); - - $("#new-param").click(function() { - launchModalForm("{% url 'part-param-template-create' %}", { - success: function() { - $("#param-table").bootstrapTable('refresh'); - }, - }); - }); - - $("#param-table").on('click', '.template-edit', function() { - var button = $(this); - - var url = "/part/parameter/template/" + button.attr('pk') + "/edit/"; - - launchModalForm(url, { - success: function() { - $("#param-table").bootstrapTable('refresh'); - } - }); - }); - - $("#param-table").on('click', '.template-delete', function() { - var button = $(this); - - var url = "/part/parameter/template/" + button.attr('pk') + "/delete/"; - - launchModalForm(url, { - success: function() { - $("#param-table").bootstrapTable('refresh'); - } - }); - }); - - $("#import-part").click(function() { - launchModalForm("{% url 'api-part-import' %}?reset", {}); - }); - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index 9fd069af52..40125019ff 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -241,6 +241,79 @@ $("#cat-param-table").on('click', '.template-delete', function() { }); }); +$("#param-table").inventreeTable({ + url: "{% url 'api-part-parameter-template-list' %}", + queryParams: { + ordering: 'name', + }, + formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; }, + columns: [ + { + field: 'pk', + title: 'ID', + visible: false, + switchable: false, + }, + { + field: 'name', + title: 'Name', + sortable: 'true', + }, + { + field: 'units', + title: 'Units', + sortable: 'true', + }, + { + formatter: function(value, row, index, field) { + var bEdit = ""; + var bDel = ""; + + var html = "
" + bEdit + bDel + "
"; + + return html; + } + } + ] +}); + +$("#new-param").click(function() { + launchModalForm("{% url 'part-param-template-create' %}", { + success: function() { + $("#param-table").bootstrapTable('refresh'); + }, + }); +}); + +$("#param-table").on('click', '.template-edit', function() { + var button = $(this); + + var url = "/part/parameter/template/" + button.attr('pk') + "/edit/"; + + launchModalForm(url, { + success: function() { + $("#param-table").bootstrapTable('refresh'); + } + }); +}); + +$("#param-table").on('click', '.template-delete', function() { + var button = $(this); + + var url = "/part/parameter/template/" + button.attr('pk') + "/delete/"; + + launchModalForm(url, { + success: function() { + $("#param-table").bootstrapTable('refresh'); + } + }); +}); + +$("#import-part").click(function() { + launchModalForm("{% url 'api-part-import' %}?reset", {}); +}); + + enableNavbar({ label: 'settings', toggleId: '#item-menu-toggle', From e72e34413d3b09e06474bcfe6d914dd4539e26b2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 7 Aug 2021 21:21:09 +1000 Subject: [PATCH 24/46] Check release tag before publishing docker image --- .github/workflows/docker_publish.yaml | 3 +++ ci/check_version_number.py | 38 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 ci/check_version_number.py diff --git a/.github/workflows/docker_publish.yaml b/.github/workflows/docker_publish.yaml index 53ec505003..9f3f3d6912 100644 --- a/.github/workflows/docker_publish.yaml +++ b/.github/workflows/docker_publish.yaml @@ -13,6 +13,9 @@ jobs: steps: - name: Check out repo uses: actions/checkout@v2 + - name: Check Release tag + run: | + python3 ci/check_version_number.py ${{ github.event.release.tag_name }} - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx diff --git a/ci/check_version_number.py b/ci/check_version_number.py new file mode 100644 index 0000000000..55a42e2484 --- /dev/null +++ b/ci/check_version_number.py @@ -0,0 +1,38 @@ +""" +On release, ensure that the release tag matches the InvenTree version number! +""" + +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import sys +import re +import os +import argparse + +if __name__ == '__main__': + + here = os.path.abspath(os.path.dirname(__file__)) + + version_file = os.path.join(here, '..', 'InvenTree', 'InvenTree', 'version.py') + + with open(version_file, 'r') as f: + + results = re.findall(r'INVENTREE_SW_VERSION = "(.*)"', f.read()) + + if not len(results) == 1: + print(f"Could not find INVENTREE_SW_VERSION in {version_file}") + sys.exit(1) + + version = results[0] + + parser = argparse.ArgumentParser() + parser.add_argument('tag', help='Version tag', action='store') + + args = parser.parse_args() + + if not args.tag == version: + print(f"Release tag '{args.tag}' does not match INVENTREE_SW_VERSION '{version}'") + sys.exit(1) + +sys.exit(0) \ No newline at end of file From 6d42cfab7588298b4df5babdf3ff616198fc696f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 7 Aug 2021 21:08:08 +1000 Subject: [PATCH 25/46] Add model validator to prevent illegal names for PartParameterTemplate (cherry picked from commit 93805a87e011b45a446b1faeae6ab80257420879) --- InvenTree/part/models.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 28fd3ce793..50d3044771 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2143,6 +2143,16 @@ class PartTestTemplate(models.Model): ) +def validate_template_name(name): + """ + Prevent illegal characters in "name" field for PartParameterTemplate + """ + + for c in "!@#$%^&*()<>{}[].,?/\|~`_+-=\'\"": + if c in str(name): + raise ValidationError(_(f"Illegal character in template name ({c})")) + + class PartParameterTemplate(models.Model): """ A PartParameterTemplate provides a template for key:value pairs for extra @@ -2181,7 +2191,15 @@ class PartParameterTemplate(models.Model): except PartParameterTemplate.DoesNotExist: pass - name = models.CharField(max_length=100, verbose_name=_('Name'), help_text=_('Parameter Name'), unique=True) + name = models.CharField( + max_length=100, + verbose_name=_('Name'), + help_text=_('Parameter Name'), + unique=True, + validators=[ + validate_template_name, + ] + ) units = models.CharField(max_length=25, verbose_name=_('Units'), help_text=_('Parameter Units'), blank=True) From 24638a72297be323ac61955c7cc1af5817cee608 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 7 Aug 2021 21:41:45 +1000 Subject: [PATCH 26/46] Add migration file --- .../0071_alter_partparametertemplate_name.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 InvenTree/part/migrations/0071_alter_partparametertemplate_name.py diff --git a/InvenTree/part/migrations/0071_alter_partparametertemplate_name.py b/InvenTree/part/migrations/0071_alter_partparametertemplate_name.py new file mode 100644 index 0000000000..fef49e73f6 --- /dev/null +++ b/InvenTree/part/migrations/0071_alter_partparametertemplate_name.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.4 on 2021-08-07 11:40 + +from django.db import migrations, models +import part.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0070_alter_part_variant_of'), + ] + + operations = [ + migrations.AlterField( + model_name='partparametertemplate', + name='name', + field=models.CharField(help_text='Parameter Name', max_length=100, unique=True, validators=[part.models.validate_template_name], verbose_name='Name'), + ), + ] From 174ac642359692ed8215269bd9cc2fae8020cb23 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 7 Aug 2021 21:45:18 +1000 Subject: [PATCH 27/46] Allow downloaded files to be inline or attachments --- InvenTree/InvenTree/helpers.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 628fd2e646..319b88cb09 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -344,13 +344,15 @@ def GetExportFormats(): ] -def DownloadFile(data, filename, content_type='application/text'): - """ Create a dynamic file for the user to download. +def DownloadFile(data, filename, content_type='application/text', inline=False): + """ + Create a dynamic file for the user to download. Args: data: Raw file data (string or bytes) filename: Filename for the file download content_type: Content type for the download + inline: Download "inline" or as attachment? (Default = attachment) Return: A StreamingHttpResponse object wrapping the supplied data @@ -365,7 +367,10 @@ def DownloadFile(data, filename, content_type='application/text'): response = StreamingHttpResponse(wrapper, content_type=content_type) response['Content-Length'] = len(data) - response['Content-Disposition'] = 'attachment; filename={f}'.format(f=filename) + + disposition = "inline" if inline else "attachment" + + response['Content-Disposition'] = f'{disposition}; filename={filename}' return response From d77b99c0ca2adcf7d4e73e681d818ded3fe78084 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 7 Aug 2021 21:57:41 +1000 Subject: [PATCH 28/46] Add user settings for report and labels --- InvenTree/common/models.py | 14 +++++++++++ .../templates/InvenTree/settings/navbar.html | 12 ++++++++++ .../InvenTree/settings/settings.html | 2 ++ .../InvenTree/settings/user_labels.html | 23 +++++++++++++++++++ .../InvenTree/settings/user_reports.html | 23 +++++++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 InvenTree/templates/InvenTree/settings/user_labels.html create mode 100644 InvenTree/templates/InvenTree/settings/user_reports.html diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 839780d5b4..e2486c41df 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -926,6 +926,20 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): 'validator': bool, }, + "LABEL_INLINE": { + 'name': _('Inline label display'), + 'description': _('Display PDF labels in the browser, instead of downloading as a file'), + 'default': True, + 'validator': bool, + }, + + "REPORT_INLINE": { + 'name': _('Inline report display'), + 'description': _('Display PDF reports in the browser, instead of downloading as a file'), + 'default': False, + 'validator': bool, + }, + 'SEARCH_PREVIEW_RESULTS': { 'name': _('Search Preview Results'), 'description': _('Number of results to show in search preview window'), diff --git a/InvenTree/templates/InvenTree/settings/navbar.html b/InvenTree/templates/InvenTree/settings/navbar.html index 83bbc10fe9..e7ea17f91f 100644 --- a/InvenTree/templates/InvenTree/settings/navbar.html +++ b/InvenTree/templates/InvenTree/settings/navbar.html @@ -30,6 +30,18 @@ +
  • + + {% trans "Labels" %} + +
  • + +
  • + + {% trans "Reports" %} + +
  • +