From 5b42ab73325d0649d65cee738ad4c30aedcd01fd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 13 Aug 2021 21:48:48 +1000 Subject: [PATCH 01/13] 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 += ` +
+ + `; + } else { + html += `
`; + } + + html += `

${group_options.title || group}

`; + + if (group_options.collapsible) { + html += `
`; + } + + 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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