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/api.py b/InvenTree/part/api.py index 789ba9b9b7..34441286ff 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,10 @@ from .models import PartAttachment, PartTestTemplate from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartCategoryParameterTemplate -from stock.models import StockItem +from company.models import Company, ManufacturerPart, SupplierPart + +from stock.models import StockItem, StockLocation + from common.models import InvenTreeSetting from build.models import Build @@ -630,6 +635,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 +643,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,21 +688,97 @@ 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: - stock_item = StockItem( + initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', '')) + + 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')], + }) + + 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_quantity, + location=initial_stock_location, + ) + + 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)): + + 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, - quantity=initial_stock, - location=part.default_location, + manufacturer=manufacturer, + MPN=mpn ) + else: + # No manufacturer part data specified + manufacturer_part = None - stock_item.save(user=request.user) + if supplier or sku: + if not supplier: + raise ValidationError({ + 'supplier': [_("This field is required")] + }) + if not sku: + raise ValidationError({ + 'SKU': [_("This field is required")] + }) - except: - pass + SupplierPart.objects.create( + part=part, + supplier=supplier, + SKU=sku, + manufacturer_part=manufacturer_part, + ) headers = self.get_success_headers(serializer.data) 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/part/test_api.py b/InvenTree/part/test_api.py index bbd73b73e0..ebef21b84b 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase): 'location', 'bom', 'test_templates', + 'company', ] roles = [ @@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase): self.assertFalse(response.data['active']) self.assertFalse(response.data['purchaseable']) + def test_initial_stock(self): + """ + Tests for initial stock quantity creation + """ + + url = reverse('api-part-list') + + # Track how many parts exist at the start of this test + n = Part.objects.count() + + # Set up required part data + data = { + 'category': 1, + 'name': "My lil' test part", + 'description': 'A part with which to test', + } + + # Signal that we want to add initial stock + data['initial_stock'] = True + + # Post without a quantity + response = self.post(url, data, expected_code=400) + self.assertIn('initial_stock_quantity', response.data) + + # Post with an invalid quantity + data['initial_stock_quantity'] = "ax" + response = self.post(url, data, expected_code=400) + self.assertIn('initial_stock_quantity', response.data) + + # Post with a negative quantity + data['initial_stock_quantity'] = -1 + response = self.post(url, data, expected_code=400) + self.assertIn('Must be greater than zero', response.data['initial_stock_quantity']) + + # Post with a valid quantity + data['initial_stock_quantity'] = 12345 + + response = self.post(url, data, expected_code=400) + self.assertIn('initial_stock_location', response.data) + + # Check that the number of parts has not increased (due to form failures) + self.assertEqual(Part.objects.count(), n) + + # Now, set a location + data['initial_stock_location'] = 1 + + response = self.post(url, data, expected_code=201) + + # Check that the part has been created + self.assertEqual(Part.objects.count(), n + 1) + + pk = response.data['pk'] + + new_part = Part.objects.get(pk=pk) + + self.assertEqual(new_part.total_stock, 12345) + + def test_initial_supplier_data(self): + """ + Tests for initial creation of supplier / manufacturer data + """ + + url = reverse('api-part-list') + + n = Part.objects.count() + + # Set up initial part data + data = { + 'category': 1, + 'name': 'Buy Buy Buy', + 'description': 'A purchaseable part', + 'purchaseable': True, + } + + # Signal that we wish to create initial supplier data + data['add_supplier_info'] = True + + # Specify MPN but not manufacturer + data['MPN'] = 'MPN-123' + + response = self.post(url, data, expected_code=400) + self.assertIn('manufacturer', response.data) + + # Specify manufacturer but not MPN + del data['MPN'] + data['manufacturer'] = 1 + response = self.post(url, data, expected_code=400) + self.assertIn('MPN', response.data) + + # Specify SKU but not supplier + del data['manufacturer'] + data['SKU'] = 'SKU-123' + response = self.post(url, data, expected_code=400) + self.assertIn('supplier', response.data) + + # Specify supplier but not SKU + del data['SKU'] + data['supplier'] = 1 + response = self.post(url, data, expected_code=400) + self.assertIn('SKU', response.data) + + # Check that no new parts have been created + self.assertEqual(Part.objects.count(), n) + + # Now, fully specify the details + data['SKU'] = 'SKU-123' + data['supplier'] = 3 + data['MPN'] = 'MPN-123' + data['manufacturer'] = 6 + + response = self.post(url, data, expected_code=201) + + self.assertEqual(Part.objects.count(), n + 1) + + pk = response.data['pk'] + + new_part = Part.objects.get(pk=pk) + + # Check that there is a new manufacturer part *and* a new supplier part + self.assertEqual(new_part.supplier_parts.count(), 1) + self.assertEqual(new_part.manufacturer_parts.count(), 1) + class PartDetailTests(InvenTreeAPITestCase): """ diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 4b41623fbf..7576f27670 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -264,6 +264,10 @@ function constructForm(url, options) { // Default HTTP method options.method = options.method || 'PATCH'; + // Default "groups" definition + options.groups = options.groups || {}; + options.current_group = null; + // Construct an "empty" data object if not provided if (!options.data) { options.data = {}; @@ -362,6 +366,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; @@ -379,52 +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; - } - - // Field prefix - if (field_options.prefix) { - fields[field].prefix = field_options.prefix; - } else if (field_options.icon) { - // Specify icon like 'fa-user' - fields[field].prefix = ``; - } - - fields[field].hidden = field_options.hidden; - - if (field_options.read_only != null) { - fields[field].read_only = field_options.read_only; + if (opt == 'filters') { + // ignore filters (see above) + } else if (opt == 'icon') { + // Specify custom icon + fields[field].prefix = ``; + } else { + fields[field][opt] = field_options[opt]; + } } } } @@ -465,8 +443,10 @@ function constructFormBody(fields, options) { html += constructField(name, field, options); } - // TODO: Dynamically create the modals, - // so that we can have an infinite number of stacks! + if (options.current_group) { + // Close out the current group + html += ``; + } // Create a new modal if one does not exists if (!options.modal) { @@ -535,6 +515,8 @@ function constructFormBody(fields, options) { submitFormData(fields, options); } }); + + initializeGroups(fields, options); } @@ -860,9 +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" %} + +
` ); @@ -932,7 +917,10 @@ function addFieldCallbacks(fields, options) { function addFieldCallback(name, field, options) { $(options.modal).find(`#id_${name}`).change(function() { - field.onEdit(name, field, options); + + var value = getFormFieldValue(name, field, options); + + field.onEdit(value, name, field, options); }); } @@ -960,6 +948,71 @@ function addClearCallback(name, field, options) { } +// Initialize callbacks and initial states for groups +function initializeGroups(fields, options) { + + var modal = options.modal; + + // Callback for when the group is expanded + $(modal).find('.form-panel-content').on('show.bs.collapse', function() { + + var panel = $(this).closest('.form-panel'); + var group = panel.attr('group'); + + var icon = $(modal).find(`#group-icon-${group}`); + + icon.removeClass('fa-angle-right'); + icon.addClass('fa-angle-up'); + }); + + // Callback for when the group is collapsed + $(modal).find('.form-panel-content').on('hide.bs.collapse', function() { + + var panel = $(this).closest('.form-panel'); + var group = panel.attr('group'); + + var icon = $(modal).find(`#group-icon-${group}`); + + icon.removeClass('fa-angle-up'); + icon.addClass('fa-angle-right'); + }); + + // Set initial state of each specified group + for (var group in options.groups) { + + var group_options = options.groups[group]; + + if (group_options.collapsed) { + $(modal).find(`#form-panel-content-${group}`).collapse("hide"); + } else { + $(modal).find(`#form-panel-content-${group}`).collapse("show"); + } + + if (group_options.hidden) { + hideFormGroup(group, options); + } + } +} + +// Hide a form group +function hideFormGroup(group, options) { + $(options.modal).find(`#form-panel-${group}`).hide(); +} + +// Show a form group +function showFormGroup(group, options) { + $(options.modal).find(`#form-panel-${group}`).show(); +} + +function setFormGroupVisibility(group, vis, options) { + if (vis) { + showFormGroup(group, options); + } else { + hideFormGroup(group, options); + } +} + + function initializeRelatedFields(fields, options) { var field_names = options.field_names; @@ -1353,6 +1406,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 +1420,58 @@ function constructField(name, parameters, options) { return constructHiddenInput(name, parameters, options); } + // Are we ending a group? + if (options.current_group && parameters.group != options.current_group) { + html += ``; + + // Null out the current "group" so we can start a new one + options.current_group = null; + } + + // Are we starting a new group? + if (parameters.group) { + + var group = parameters.group; + + var group_options = options.groups[group] || {}; + + // Are we starting a new group? + // Add HTML for the start of a separate panel + if (parameters.group != options.current_group) { + + html += ` +
+
`; + if (group_options.collapsible) { + html += ` +
+ + `; + } 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) { @@ -1428,13 +1528,14 @@ function constructField(name, parameters, options) { html += `
`; // input-group } - // Div for error messages - html += `
`; - if (parameters.help_text) { html += constructHelpText(name, parameters, options); } + // Div for error messages + html += `
`; + + html += `
`; // controls html += `
`; // form-group @@ -1599,6 +1700,10 @@ function constructInputOptions(name, classes, type, parameters) { opts.push(`placeholder='${parameters.placeholder}'`); } + if (parameters.type == 'boolean') { + opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`); + } + if (parameters.multiline) { return ``; } else { @@ -1772,7 +1877,13 @@ function constructCandyInput(name, parameters, options) { */ function constructHelpText(name, parameters, options) { - var html = `
${parameters.help_text}
`; + var style = ''; + + if (parameters.type == 'boolean') { + style = `style='display: inline-block; margin-left: 25px' `; + } + + var html = `
${parameters.help_text}
`; return html; } \ No newline at end of file diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 3d8a21c66a..bf9b5f316f 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -13,6 +13,31 @@ function yesNoLabel(value) { } } + +function partGroups(options={}) { + + return { + attributes: { + title: '{% trans "Part Attributes" %}', + collapsible: true, + }, + create: { + title: '{% trans "Part Creation Options" %}', + collapsible: true, + }, + duplicate: { + title: '{% trans "Part Duplication Options" %}', + collapsible: true, + }, + supplier: { + title: '{% trans "Supplier Options" %}', + collapsible: true, + hidden: !global_settings.PART_PURCHASEABLE, + } + } + +} + // Construct fieldset for part forms function partFields(options={}) { @@ -48,36 +73,44 @@ function partFields(options={}) { minimum_stock: { icon: 'fa-boxes', }, - attributes: { - type: 'candy', - html: `

{% trans "Part Attributes" %}


` - }, component: { value: global_settings.PART_COMPONENT, + group: 'attributes', }, assembly: { value: global_settings.PART_ASSEMBLY, + group: 'attributes', }, is_template: { value: global_settings.PART_TEMPLATE, + group: 'attributes', }, trackable: { value: global_settings.PART_TRACKABLE, + group: 'attributes', }, purchaseable: { value: global_settings.PART_PURCHASEABLE, + group: 'attributes', + onEdit: function(value, name, field, options) { + setFormGroupVisibility('supplier', value, options); + } }, salable: { value: global_settings.PART_SALABLE, + group: 'attributes', }, virtual: { value: global_settings.PART_VIRTUAL, + group: 'attributes', }, }; // If editing a part, we can set the "active" status if (options.edit) { - fields.active = {}; + fields.active = { + group: 'attributes' + }; } // Pop expiry field @@ -91,16 +124,32 @@ function partFields(options={}) { // No supplier parts available yet delete fields["default_supplier"]; - fields.create = { - type: 'candy', - html: `

{% trans "Part Creation Options" %}


`, - }; - if (global_settings.PART_CREATE_INITIAL) { + fields.initial_stock = { + type: 'boolean', + label: '{% trans "Create Initial Stock" %}', + help_text: '{% trans "Create an initial stock item for this part" %}', + group: 'create', + }; + + fields.initial_stock_quantity = { type: 'decimal', + value: 1, label: '{% trans "Initial Stock Quantity" %}', - help_text: '{% trans "Initialize part stock with specified quantity" %}', + help_text: '{% trans "Specify initial stock quantity for this part" %}', + group: 'create', + }; + + // TODO - Allow initial location of stock to be specified + fields.initial_stock_location = { + label: '{% trans "Location" %}', + help_text: '{% trans "Select destination stock location" %}', + type: 'related field', + required: true, + api_url: `/api/stock/location/`, + model: 'stocklocation', + group: 'create', }; } @@ -109,21 +158,65 @@ function partFields(options={}) { label: '{% trans "Copy Category Parameters" %}', help_text: '{% trans "Copy parameter templates from selected part category" %}', value: global_settings.PART_CATEGORY_PARAMETERS, + group: 'create', }; + + // Supplier options + fields.add_supplier_info = { + type: 'boolean', + label: '{% trans "Add Supplier Data" %}', + help_text: '{% trans "Create initial supplier data for this part" %}', + group: 'supplier', + }; + + fields.supplier = { + type: 'related field', + model: 'company', + label: '{% trans "Supplier" %}', + help_text: '{% trans "Select supplier" %}', + filters: { + 'is_supplier': true, + }, + api_url: '{% url "api-company-list" %}', + group: 'supplier', + }; + + fields.SKU = { + type: 'string', + label: '{% trans "SKU" %}', + help_text: '{% trans "Supplier stock keeping unit" %}', + group: 'supplier', + }; + + fields.manufacturer = { + type: 'related field', + model: 'company', + label: '{% trans "Manufacturer" %}', + help_text: '{% trans "Select manufacturer" %}', + filters: { + 'is_manufacturer': true, + }, + api_url: '{% url "api-company-list" %}', + group: 'supplier', + }; + + fields.MPN = { + type: 'string', + label: '{% trans "MPN" %}', + help_text: '{% trans "Manufacturer Part Number" %}', + group: 'supplier', + }; + } // Additional fields when "duplicating" a part if (options.duplicate) { - fields.duplicate = { - type: 'candy', - html: `

{% trans "Part Duplication Options" %}


`, - }; - fields.copy_from = { type: 'integer', hidden: true, value: options.duplicate, + group: 'duplicate', }, fields.copy_image = { @@ -131,6 +224,7 @@ function partFields(options={}) { label: '{% trans "Copy Image" %}', help_text: '{% trans "Copy image from original part" %}', value: true, + group: 'duplicate', }, fields.copy_bom = { @@ -138,6 +232,7 @@ function partFields(options={}) { label: '{% trans "Copy BOM" %}', help_text: '{% trans "Copy bill of materials from original part" %}', value: global_settings.PART_COPY_BOM, + group: 'duplicate', }; fields.copy_parameters = { @@ -145,6 +240,7 @@ function partFields(options={}) { label: '{% trans "Copy Parameters" %}', help_text: '{% trans "Copy parameter data from original part" %}', value: global_settings.PART_COPY_PARAMETERS, + group: 'duplicate', }; } @@ -191,8 +287,11 @@ function editPart(pk, options={}) { edit: true }); + var groups = partGroups({}); + constructForm(url, { fields: fields, + groups: partGroups(), title: '{% trans "Edit Part" %}', reload: true, }); @@ -221,6 +320,7 @@ function duplicatePart(pk, options={}) { constructForm('{% url "api-part-list" %}', { method: 'POST', fields: fields, + groups: partGroups(), title: '{% trans "Duplicate Part" %}', data: data, onSuccess: function(data) {