mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #1956 from SchrodingersGat/supplier-part-from-form
Supplier part from form
This commit is contained in:
commit
1db654e990
@ -730,6 +730,13 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-panel {
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.modal input {
|
.modal input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
@ -9,12 +9,14 @@ from django.conf.urls import url, include
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.db.models import Q, F, Count, Min, Max, Avg
|
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 django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import filters, serializers
|
from rest_framework import filters, serializers
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from django_filters import rest_framework as rest_filters
|
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.models import convert_money
|
||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
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 Part, PartCategory, BomItem
|
||||||
from .models import PartParameter, PartParameterTemplate
|
from .models import PartParameter, PartParameterTemplate
|
||||||
@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
|
|||||||
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
from .models import PartCategoryParameterTemplate
|
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 common.models import InvenTreeSetting
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
|
|
||||||
@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
else:
|
else:
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
We wish to save the user who created this part!
|
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
|
Note: Implementation copied from DRF class CreateModelMixin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# TODO: Unit tests for this function!
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Optionally create initial stock item
|
# Optionally create initial stock item
|
||||||
try:
|
initial_stock = str2bool(request.data.get('initial_stock', False))
|
||||||
initial_stock = Decimal(request.data.get('initial_stock', 0))
|
|
||||||
|
|
||||||
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,
|
part=part,
|
||||||
quantity=initial_stock,
|
manufacturer=manufacturer,
|
||||||
location=part.default_location,
|
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:
|
SupplierPart.objects.create(
|
||||||
pass
|
part=part,
|
||||||
|
supplier=supplier,
|
||||||
|
SKU=sku,
|
||||||
|
manufacturer_part=manufacturer_part,
|
||||||
|
)
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
|
||||||
|
@ -276,6 +276,7 @@
|
|||||||
constructForm('{% url "api-part-list" %}', {
|
constructForm('{% url "api-part-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
groups: partGroups(),
|
||||||
title: '{% trans "Create Part" %}',
|
title: '{% trans "Create Part" %}',
|
||||||
onSuccess: function(data) {
|
onSuccess: function(data) {
|
||||||
// Follow the new part
|
// Follow the new part
|
||||||
|
@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
'location',
|
'location',
|
||||||
'bom',
|
'bom',
|
||||||
'test_templates',
|
'test_templates',
|
||||||
|
'company',
|
||||||
]
|
]
|
||||||
|
|
||||||
roles = [
|
roles = [
|
||||||
@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertFalse(response.data['active'])
|
self.assertFalse(response.data['active'])
|
||||||
self.assertFalse(response.data['purchaseable'])
|
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):
|
class PartDetailTests(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -264,6 +264,10 @@ function constructForm(url, options) {
|
|||||||
// Default HTTP method
|
// Default HTTP method
|
||||||
options.method = options.method || 'PATCH';
|
options.method = options.method || 'PATCH';
|
||||||
|
|
||||||
|
// Default "groups" definition
|
||||||
|
options.groups = options.groups || {};
|
||||||
|
options.current_group = null;
|
||||||
|
|
||||||
// Construct an "empty" data object if not provided
|
// Construct an "empty" data object if not provided
|
||||||
if (!options.data) {
|
if (!options.data) {
|
||||||
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
|
// Provide each field object with its own name
|
||||||
for(field in fields) {
|
for(field in fields) {
|
||||||
fields[field].name = field;
|
fields[field].name = field;
|
||||||
@ -379,52 +391,18 @@ function constructFormBody(fields, options) {
|
|||||||
// Override existing query filters (if provided!)
|
// Override existing query filters (if provided!)
|
||||||
fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);
|
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
|
var val = field_options[opt];
|
||||||
fields[field].before = field_options.before;
|
|
||||||
fields[field].after = field_options.after;
|
|
||||||
|
|
||||||
// Secondary modal options
|
if (opt == 'filters') {
|
||||||
fields[field].secondary = field_options.secondary;
|
// ignore filters (see above)
|
||||||
|
} else if (opt == 'icon') {
|
||||||
// Edit callback
|
// Specify custom icon
|
||||||
fields[field].onEdit = field_options.onEdit;
|
fields[field].prefix = `<span class='fas ${val}'></span>`;
|
||||||
|
} else {
|
||||||
fields[field].multiline = field_options.multiline;
|
fields[field][opt] = field_options[opt];
|
||||||
|
}
|
||||||
// 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 = `<span class='fas ${field_options.icon}'></span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
fields[field].hidden = field_options.hidden;
|
|
||||||
|
|
||||||
if (field_options.read_only != null) {
|
|
||||||
fields[field].read_only = field_options.read_only;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -465,8 +443,10 @@ function constructFormBody(fields, options) {
|
|||||||
html += constructField(name, field, options);
|
html += constructField(name, field, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Dynamically create the modals,
|
if (options.current_group) {
|
||||||
// so that we can have an infinite number of stacks!
|
// Close out the current group
|
||||||
|
html += `</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new modal if one does not exists
|
// Create a new modal if one does not exists
|
||||||
if (!options.modal) {
|
if (!options.modal) {
|
||||||
@ -535,6 +515,8 @@ function constructFormBody(fields, options) {
|
|||||||
submitFormData(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');
|
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(
|
non_field_errors.append(
|
||||||
`<div class='alert alert-block alert-danger'>
|
`<div class='alert alert-block alert-danger'>
|
||||||
<b>{% trans "Form errors exist" %}</b>
|
<b>{% trans "Form errors exist" %}</b>
|
||||||
|
<span id='form-errors-info' class='float-right fas fa-info-circle icon-red'>
|
||||||
|
</span>
|
||||||
</div>`
|
</div>`
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -932,7 +917,10 @@ function addFieldCallbacks(fields, options) {
|
|||||||
function addFieldCallback(name, field, options) {
|
function addFieldCallback(name, field, options) {
|
||||||
|
|
||||||
$(options.modal).find(`#id_${name}`).change(function() {
|
$(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) {
|
function initializeRelatedFields(fields, options) {
|
||||||
|
|
||||||
var field_names = options.field_names;
|
var field_names = options.field_names;
|
||||||
@ -1353,6 +1406,8 @@ function renderModelData(name, model, data, parameters, options) {
|
|||||||
*/
|
*/
|
||||||
function constructField(name, parameters, options) {
|
function constructField(name, parameters, options) {
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
|
||||||
// Shortcut for simple visual fields
|
// Shortcut for simple visual fields
|
||||||
if (parameters.type == 'candy') {
|
if (parameters.type == 'candy') {
|
||||||
return constructCandyInput(name, parameters, options);
|
return constructCandyInput(name, parameters, options);
|
||||||
@ -1365,13 +1420,58 @@ function constructField(name, parameters, options) {
|
|||||||
return constructHiddenInput(name, parameters, options);
|
return constructHiddenInput(name, parameters, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Are we ending a group?
|
||||||
|
if (options.current_group && parameters.group != options.current_group) {
|
||||||
|
html += `</div></div>`;
|
||||||
|
|
||||||
|
// 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 += `
|
||||||
|
<div class='panel form-panel' id='form-panel-${group}' group='${group}'>
|
||||||
|
<div class='panel-heading form-panel-heading' id='form-panel-heading-${group}'>`;
|
||||||
|
if (group_options.collapsible) {
|
||||||
|
html += `
|
||||||
|
<div data-toggle='collapse' data-target='#form-panel-content-${group}'>
|
||||||
|
<a href='#'><span id='group-icon-${group}' class='fas fa-angle-up'></span>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
html += `<div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<h4 style='display: inline;'>${group_options.title || group}</h4>`;
|
||||||
|
|
||||||
|
if (group_options.collapsible) {
|
||||||
|
html += `</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</div></div>
|
||||||
|
<div class='panel-content form-panel-content' id='form-panel-content-${group}'>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep track of the group we are in
|
||||||
|
options.current_group = group;
|
||||||
|
}
|
||||||
|
|
||||||
var form_classes = 'form-group';
|
var form_classes = 'form-group';
|
||||||
|
|
||||||
if (parameters.errors) {
|
if (parameters.errors) {
|
||||||
form_classes += ' has-error';
|
form_classes += ' has-error';
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '';
|
|
||||||
|
|
||||||
// Optional content to render before the field
|
// Optional content to render before the field
|
||||||
if (parameters.before) {
|
if (parameters.before) {
|
||||||
@ -1428,13 +1528,14 @@ function constructField(name, parameters, options) {
|
|||||||
html += `</div>`; // input-group
|
html += `</div>`; // input-group
|
||||||
}
|
}
|
||||||
|
|
||||||
// Div for error messages
|
|
||||||
html += `<div id='errors-${name}'></div>`;
|
|
||||||
|
|
||||||
if (parameters.help_text) {
|
if (parameters.help_text) {
|
||||||
html += constructHelpText(name, parameters, options);
|
html += constructHelpText(name, parameters, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Div for error messages
|
||||||
|
html += `<div id='errors-${name}'></div>`;
|
||||||
|
|
||||||
|
|
||||||
html += `</div>`; // controls
|
html += `</div>`; // controls
|
||||||
html += `</div>`; // form-group
|
html += `</div>`; // form-group
|
||||||
|
|
||||||
@ -1599,6 +1700,10 @@ function constructInputOptions(name, classes, type, parameters) {
|
|||||||
opts.push(`placeholder='${parameters.placeholder}'`);
|
opts.push(`placeholder='${parameters.placeholder}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parameters.type == 'boolean') {
|
||||||
|
opts.push(`style='display: inline-block; width: 20px; margin-right: 20px;'`);
|
||||||
|
}
|
||||||
|
|
||||||
if (parameters.multiline) {
|
if (parameters.multiline) {
|
||||||
return `<textarea ${opts.join(' ')}></textarea>`;
|
return `<textarea ${opts.join(' ')}></textarea>`;
|
||||||
} else {
|
} else {
|
||||||
@ -1772,7 +1877,13 @@ function constructCandyInput(name, parameters, options) {
|
|||||||
*/
|
*/
|
||||||
function constructHelpText(name, parameters, options) {
|
function constructHelpText(name, parameters, options) {
|
||||||
|
|
||||||
var html = `<div id='hint_id_${name}' class='help-block'><i>${parameters.help_text}</i></div>`;
|
var style = '';
|
||||||
|
|
||||||
|
if (parameters.type == 'boolean') {
|
||||||
|
style = `style='display: inline-block; margin-left: 25px' `;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = `<div id='hint_id_${name}' ${style}class='help-block'><i>${parameters.help_text}</i></div>`;
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
@ -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
|
// Construct fieldset for part forms
|
||||||
function partFields(options={}) {
|
function partFields(options={}) {
|
||||||
|
|
||||||
@ -48,36 +73,44 @@ function partFields(options={}) {
|
|||||||
minimum_stock: {
|
minimum_stock: {
|
||||||
icon: 'fa-boxes',
|
icon: 'fa-boxes',
|
||||||
},
|
},
|
||||||
attributes: {
|
|
||||||
type: 'candy',
|
|
||||||
html: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
|
|
||||||
},
|
|
||||||
component: {
|
component: {
|
||||||
value: global_settings.PART_COMPONENT,
|
value: global_settings.PART_COMPONENT,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
assembly: {
|
assembly: {
|
||||||
value: global_settings.PART_ASSEMBLY,
|
value: global_settings.PART_ASSEMBLY,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
is_template: {
|
is_template: {
|
||||||
value: global_settings.PART_TEMPLATE,
|
value: global_settings.PART_TEMPLATE,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
trackable: {
|
trackable: {
|
||||||
value: global_settings.PART_TRACKABLE,
|
value: global_settings.PART_TRACKABLE,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
purchaseable: {
|
purchaseable: {
|
||||||
value: global_settings.PART_PURCHASEABLE,
|
value: global_settings.PART_PURCHASEABLE,
|
||||||
|
group: 'attributes',
|
||||||
|
onEdit: function(value, name, field, options) {
|
||||||
|
setFormGroupVisibility('supplier', value, options);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
salable: {
|
salable: {
|
||||||
value: global_settings.PART_SALABLE,
|
value: global_settings.PART_SALABLE,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
virtual: {
|
virtual: {
|
||||||
value: global_settings.PART_VIRTUAL,
|
value: global_settings.PART_VIRTUAL,
|
||||||
|
group: 'attributes',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// If editing a part, we can set the "active" status
|
// If editing a part, we can set the "active" status
|
||||||
if (options.edit) {
|
if (options.edit) {
|
||||||
fields.active = {};
|
fields.active = {
|
||||||
|
group: 'attributes'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pop expiry field
|
// Pop expiry field
|
||||||
@ -91,16 +124,32 @@ function partFields(options={}) {
|
|||||||
// No supplier parts available yet
|
// No supplier parts available yet
|
||||||
delete fields["default_supplier"];
|
delete fields["default_supplier"];
|
||||||
|
|
||||||
fields.create = {
|
|
||||||
type: 'candy',
|
|
||||||
html: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (global_settings.PART_CREATE_INITIAL) {
|
if (global_settings.PART_CREATE_INITIAL) {
|
||||||
|
|
||||||
fields.initial_stock = {
|
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',
|
type: 'decimal',
|
||||||
|
value: 1,
|
||||||
label: '{% trans "Initial Stock Quantity" %}',
|
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" %}',
|
label: '{% trans "Copy Category Parameters" %}',
|
||||||
help_text: '{% trans "Copy parameter templates from selected part category" %}',
|
help_text: '{% trans "Copy parameter templates from selected part category" %}',
|
||||||
value: global_settings.PART_CATEGORY_PARAMETERS,
|
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
|
// Additional fields when "duplicating" a part
|
||||||
if (options.duplicate) {
|
if (options.duplicate) {
|
||||||
|
|
||||||
fields.duplicate = {
|
|
||||||
type: 'candy',
|
|
||||||
html: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
|
|
||||||
};
|
|
||||||
|
|
||||||
fields.copy_from = {
|
fields.copy_from = {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
value: options.duplicate,
|
value: options.duplicate,
|
||||||
|
group: 'duplicate',
|
||||||
},
|
},
|
||||||
|
|
||||||
fields.copy_image = {
|
fields.copy_image = {
|
||||||
@ -131,6 +224,7 @@ function partFields(options={}) {
|
|||||||
label: '{% trans "Copy Image" %}',
|
label: '{% trans "Copy Image" %}',
|
||||||
help_text: '{% trans "Copy image from original part" %}',
|
help_text: '{% trans "Copy image from original part" %}',
|
||||||
value: true,
|
value: true,
|
||||||
|
group: 'duplicate',
|
||||||
},
|
},
|
||||||
|
|
||||||
fields.copy_bom = {
|
fields.copy_bom = {
|
||||||
@ -138,6 +232,7 @@ function partFields(options={}) {
|
|||||||
label: '{% trans "Copy BOM" %}',
|
label: '{% trans "Copy BOM" %}',
|
||||||
help_text: '{% trans "Copy bill of materials from original part" %}',
|
help_text: '{% trans "Copy bill of materials from original part" %}',
|
||||||
value: global_settings.PART_COPY_BOM,
|
value: global_settings.PART_COPY_BOM,
|
||||||
|
group: 'duplicate',
|
||||||
};
|
};
|
||||||
|
|
||||||
fields.copy_parameters = {
|
fields.copy_parameters = {
|
||||||
@ -145,6 +240,7 @@ function partFields(options={}) {
|
|||||||
label: '{% trans "Copy Parameters" %}',
|
label: '{% trans "Copy Parameters" %}',
|
||||||
help_text: '{% trans "Copy parameter data from original part" %}',
|
help_text: '{% trans "Copy parameter data from original part" %}',
|
||||||
value: global_settings.PART_COPY_PARAMETERS,
|
value: global_settings.PART_COPY_PARAMETERS,
|
||||||
|
group: 'duplicate',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,8 +287,11 @@ function editPart(pk, options={}) {
|
|||||||
edit: true
|
edit: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var groups = partGroups({});
|
||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
groups: partGroups(),
|
||||||
title: '{% trans "Edit Part" %}',
|
title: '{% trans "Edit Part" %}',
|
||||||
reload: true,
|
reload: true,
|
||||||
});
|
});
|
||||||
@ -221,6 +320,7 @@ function duplicatePart(pk, options={}) {
|
|||||||
constructForm('{% url "api-part-list" %}', {
|
constructForm('{% url "api-part-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: fields,
|
fields: fields,
|
||||||
|
groups: partGroups(),
|
||||||
title: '{% trans "Duplicate Part" %}',
|
title: '{% trans "Duplicate Part" %}',
|
||||||
data: data,
|
data: data,
|
||||||
onSuccess: function(data) {
|
onSuccess: function(data) {
|
||||||
|
Loading…
Reference in New Issue
Block a user