mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	Merge pull request #1956 from SchrodingersGat/supplier-part-from-form
Supplier part from form
This commit is contained in:
		@@ -730,6 +730,13 @@
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-panel {
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
    border: 1px solid #ccc;
 | 
			
		||||
    padding: 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.modal input {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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):
 | 
			
		||||
    """
 | 
			
		||||
 
 | 
			
		||||
@@ -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 = `<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;
 | 
			
		||||
                if (opt == 'filters') {
 | 
			
		||||
                    // ignore filters (see above)
 | 
			
		||||
                } else if (opt == 'icon') {
 | 
			
		||||
                    // Specify custom icon
 | 
			
		||||
                    fields[field].prefix = `<span class='fas ${val}'></span>`;
 | 
			
		||||
                } 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 += `</div></div>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 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(
 | 
			
		||||
        `<div class='alert alert-block alert-danger'>
 | 
			
		||||
            <b>{% trans "Form errors exist" %}</b>
 | 
			
		||||
            <span id='form-errors-info' class='float-right fas fa-info-circle icon-red'>
 | 
			
		||||
            </span>
 | 
			
		||||
        </div>`
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@@ -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 += `</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';
 | 
			
		||||
 | 
			
		||||
    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 += `</div>`;   // input-group
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Div for error messages
 | 
			
		||||
    html += `<div id='errors-${name}'></div>`;
 | 
			
		||||
 | 
			
		||||
    if (parameters.help_text) {
 | 
			
		||||
        html += constructHelpText(name, parameters, options);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Div for error messages
 | 
			
		||||
    html += `<div id='errors-${name}'></div>`;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    html += `</div>`;   // controls
 | 
			
		||||
    html += `</div>`;   // 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 `<textarea ${opts.join(' ')}></textarea>`;
 | 
			
		||||
    } else {
 | 
			
		||||
@@ -1772,7 +1877,13 @@ function constructCandyInput(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;
 | 
			
		||||
}
 | 
			
		||||
@@ -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: `<hr><h4><i>{% trans "Part Attributes" %}</i></h4><hr>`
 | 
			
		||||
        },
 | 
			
		||||
        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: `<hr><h4><i>{% trans "Part Creation Options" %}</i></h4><hr>`,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        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: `<hr><h4><i>{% trans "Part Duplication Options" %}</i></h4><hr>`,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        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) {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user