diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index f76469bf76..778a1657ab 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,17 +2,24 @@ # InvenTree API version -INVENTREE_API_VERSION = 90 +INVENTREE_API_VERSION = 91 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about + +v91 -> 2023-01-31 : https://github.com/inventree/InvenTree/pull/4281 + - Improves the API endpoint for creating new Part instances + v90 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4186/files - Adds a dedicated endpoint to activate a plugin + v89 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4214 - Adds updated field to SupplierPart API - Adds API date orddering for supplier part list + v88 -> 2023-01-17: https://github.com/inventree/InvenTree/pull/4225 - Adds 'priority' field to Build model and api endpoints + v87 -> 2023-01-04 : https://github.com/inventree/InvenTree/pull/4067 - Add API date filter for stock table on Expiry date diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index e60e3495a5..a7d46745ae 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -147,6 +147,16 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return initials + def skip_create_fields(self): + """Return a list of 'fields' which should be skipped for model creation. + + This is used to 'bypass' a shortcoming of the DRF framework, + which does not allow us to have writeable serializer fields which do not exist on the model. + + Default implementation returns an empty list + """ + return [] + def save(self, **kwargs): """Catch any django ValidationError thrown at the moment `save` is called, and re-throw as a DRF ValidationError.""" try: @@ -156,6 +166,17 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return self.instance + def create(self, validated_data): + """Custom create method which supports field adjustment""" + + initial_data = validated_data.copy() + + # Remove any fields which do not exist on the model + for field in self.skip_create_fields(): + initial_data.pop(field, None) + + return super().create(initial_data) + def update(self, instance, validated_data): """Catch any django ValidationError, and re-throw as a DRF ValidationError.""" try: @@ -171,14 +192,21 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): In addition to running validators on the serializer fields, this class ensures that the underlying model is also validated. """ + # Run any native validation checks first (may raise a ValidationError) data = super().run_validation(data) - # Now ensure the underlying model is correct - if not hasattr(self, 'instance') or self.instance is None: # No instance exists (we are creating a new one) - instance = self.Meta.model(**data) + + initial_data = data.copy() + + for field in self.skip_create_fields(): + # Remove any fields we do not wish to provide to the model + initial_data.pop(field, None) + + # Create a (RAM only) instance for extra testing + instance = self.Meta.model(**initial_data) else: # Instance already exists (we are updating!) instance = self.instance @@ -599,6 +627,13 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass): Adds the optional, write-only `remote_image` field to the serializer """ + def skip_create_fields(self): + """Ensure the 'remote_image' field is skipped when creating a new instance""" + + return [ + 'remote_image', + ] + remote_image = serializers.URLField( required=False, allow_blank=False, diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 52d2bc3666..283a386c59 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1121,12 +1121,19 @@ class InvenTreeSetting(BaseInvenTreeSetting): }, 'PART_CREATE_INITIAL': { - 'name': _('Create initial stock'), - 'description': _('Create initial stock on part creation'), + 'name': _('Initial Stock Data'), + 'description': _('Allow creation of initial stock when adding a new part'), 'default': False, 'validator': bool, }, + 'PART_CREATE_SUPPLIER': { + 'name': _('Initial Supplier Data'), + 'description': _('Allow creation of initial supplier data when adding a new part'), + 'default': True, + 'validator': bool, + }, + 'PART_NAME_FORMAT': { 'name': _('Part Name Display Format'), 'description': _('Format to display the part name'), diff --git a/InvenTree/company/fixtures/company.yaml b/InvenTree/company/fixtures/company.yaml index 02a0a4e830..8ce1071374 100644 --- a/InvenTree/company/fixtures/company.yaml +++ b/InvenTree/company/fixtures/company.yaml @@ -46,3 +46,12 @@ name: Another manufacturer description: They build things and sell it to us is_manufacturer: True + +- model: company.company + pk: 8 + fields: + name: Customer only + description: Just a customer + is_customer: True + is_supplier: False + is_manufacturer: False diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index a7310390b0..6add14d125 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -94,17 +94,6 @@ class Company(MetadataMixin, models.Model): ] verbose_name_plural = "Companies" - def __init__(self, *args, **kwargs): - """Custom initialization routine for the Company model. - - Ensures that custom serializer fields (without matching model fields) are removed - """ - - # Remote image specified during creation via API - kwargs.pop('remote_image', None) - - super().__init__(*args, **kwargs) - name = models.CharField(max_length=100, blank=False, help_text=_('Company name'), verbose_name=_('Company name')) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a7cdc08d45..079f5d373e 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1,7 +1,6 @@ """Provides a JSON API for the Part app.""" import functools -from decimal import Decimal, InvalidOperation from django.db import transaction from django.db.models import Count, F, Q @@ -18,7 +17,6 @@ from rest_framework.response import Response import order.models from build.models import Build, BuildItem -from company.models import Company, ManufacturerPart, SupplierPart from InvenTree.api import (APIDownloadMixin, AttachmentMixin, ListCreateDestroyAPIView) from InvenTree.filters import InvenTreeOrderingFilter @@ -33,7 +31,6 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, SalesOrderStatus) from part.admin import PartCategoryResource, PartResource from plugin.serializers import MetadataSerializer -from stock.models import StockItem, StockLocation from . import serializers as part_serializers from . import views @@ -1096,25 +1093,7 @@ class PartFilter(rest_filters.FilterSet): class PartList(APIDownloadMixin, ListCreateAPI): - """API endpoint for accessing a list of Part objects. - - - GET: Return list of objects - - POST: Create a new Part object - - The Part object list can be filtered by: - - category: Filter by PartCategory reference - - cascade: If true, include parts from sub-categories - - starred: Is the part "starred" by the current user? - - is_template: Is the part a template part? - - variant_of: Filter by variant_of Part reference - - assembly: Filter by assembly field - - component: Filter by component field - - trackable: Filter by trackable field - - purchaseable: Filter by purcahseable field - - salable: Filter by salable field - - active: Filter by active field - - ancestor: Filter parts by 'ancestor' (template / variant tree) - """ + """API endpoint for accessing a list of Part objects, or creating a new Part instance""" serializer_class = part_serializers.PartSerializer queryset = Part.objects.all() @@ -1127,6 +1106,9 @@ class PartList(APIDownloadMixin, ListCreateAPI): # Ensure the request context is passed through kwargs['context'] = self.get_serializer_context() + # Indicate that we can create a new Part via this endpoint + kwargs['create'] = True + # Pass a list of "starred" parts to the current user to the serializer # We do this to reduce the number of database queries required! if self.starred_parts is None and self.request is not None: @@ -1144,6 +1126,13 @@ class PartList(APIDownloadMixin, ListCreateAPI): return self.serializer_class(*args, **kwargs) + def get_serializer_context(self): + """Extend serializer context data""" + context = super().get_serializer_context() + context['request'] = self.request + + return context + def download_queryset(self, queryset, export_format): """Download the filtered queryset as a data file""" dataset = PartResource().export(queryset=queryset) @@ -1241,127 +1230,6 @@ class PartList(APIDownloadMixin, ListCreateAPI): part.save(**{'add_category_templates': copy_templates}) - # Optionally copy data from another part (e.g. when duplicating) - copy_from = data.get('copy_from', None) - - if copy_from is not None: - - try: - original = Part.objects.get(pk=copy_from) - - copy_bom = str2bool(data.get('copy_bom', False)) - copy_parameters = str2bool(data.get('copy_parameters', False)) - copy_image = str2bool(data.get('copy_image', True)) - - # Copy image? - if copy_image: - part.image = original.image - part.save() - - # Copy BOM? - if copy_bom: - part.copy_bom_from(original) - - # Copy parameter data? - if copy_parameters: - part.copy_parameters_from(original) - - except (ValueError, Part.DoesNotExist): - pass - - # Optionally create initial stock item - initial_stock = str2bool(data.get('initial_stock', False)) - - if initial_stock: - try: - - initial_stock_quantity = Decimal(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 = 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(data.get('add_supplier_info', False)): - - try: - manufacturer = Company.objects.get(pk=data.get('manufacturer', None)) - except Exception: - manufacturer = None - - try: - supplier = Company.objects.get(pk=data.get('supplier', None)) - except Exception: - supplier = None - - mpn = str(data.get('MPN', '')).strip() - sku = str(data.get('SKU', '')).strip() - - # Construct a manufacturer part - if manufacturer or mpn: - if not manufacturer: - raise ValidationError({ - 'manufacturer': [_("This field is required")] - }) - if not mpn: - raise ValidationError({ - 'MPN': [_("This field is required")] - }) - - manufacturer_part = ManufacturerPart.objects.create( - part=part, - manufacturer=manufacturer, - MPN=mpn - ) - else: - # No manufacturer part data specified - manufacturer_part = None - - if supplier or sku: - if not supplier: - raise ValidationError({ - 'supplier': [_("This field is required")] - }) - if not sku: - raise ValidationError({ - 'SKU': [_("This field is required")] - }) - - SupplierPart.objects.create( - part=part, - supplier=supplier, - SKU=sku, - manufacturer_part=manufacturer_part, - ) - headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/InvenTree/part/fixtures/params.yaml b/InvenTree/part/fixtures/params.yaml index 3056be473b..2364a95fdb 100644 --- a/InvenTree/part/fixtures/params.yaml +++ b/InvenTree/part/fixtures/params.yaml @@ -54,6 +54,20 @@ template: 3 data: 12 +- model: part.PartParameter + pk: 6 + fields: + part: 100 + template: 3 + data: 12 + +- model: part.PartParameter + pk: 7 + fields: + part: 100 + template: 1 + data: 12 + # Add some template parameters to categories (requires category.yaml) - model: part.PartCategoryParameterTemplate pk: 1 diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 1368c3f83b..18e23b1ec6 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -391,17 +391,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent parent_attr = 'variant_of' - def __init__(self, *args, **kwargs): - """Custom initialization routine for the Part model. - - Ensures that custom serializer fields (without matching model fields) are removed - """ - - # Remote image specified during creation via API - kwargs.pop('remote_image', None) - - super().__init__(*args, **kwargs) - @staticmethod def get_api_url(): """Return the list API endpoint URL associated with the Part model""" @@ -2034,41 +2023,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): parameter.save() - @transaction.atomic - def deep_copy(self, other, **kwargs): - """Duplicates non-field data from another part. - - Does not alter the normal fields of this part, but can be used to copy other data linked by ForeignKey refernce. - - Keyword Args: - image: If True, copies Part image (default = True) - bom: If True, copies BOM data (default = False) - parameters: If True, copies Parameters data (default = True) - """ - # Copy the part image - if kwargs.get('image', True): - if other.image: - # Reference the other image from this Part - self.image = other.image - - # Copy the BOM data - if kwargs.get('bom', False): - self.copy_bom_from(other) - - # Copy the parameters data - if kwargs.get('parameters', True): - self.copy_parameters_from(other) - - # Copy the fields that aren't available in the duplicate form - self.salable = other.salable - self.assembly = other.assembly - self.component = other.component - self.purchaseable = other.purchaseable - self.trackable = other.trackable - self.virtual = other.virtual - - self.save() - def getTestTemplates(self, required=None, include_parent=True): """Return a list of all test templates associated with this Part. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index a20a57911a..c8b15aac9f 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -5,6 +5,7 @@ import io from decimal import Decimal from django.core.files.base import ContentFile +from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import ExpressionWrapper, F, FloatField, Q from django.db.models.functions import Coalesce @@ -14,8 +15,10 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum +import company.models import InvenTree.helpers import part.filters +import stock.models from common.settings import currency_code_default, currency_code_mappings from InvenTree.serializers import (DataFileExtractSerializer, DataFileUploadSerializer, @@ -304,6 +307,113 @@ class PartBriefSerializer(InvenTreeModelSerializer): ] +class DuplicatePartSerializer(serializers.Serializer): + """Serializer for specifying options when duplicating a Part. + + The fields in this serializer control how the Part is duplicated. + """ + + part = serializers.PrimaryKeyRelatedField( + queryset=Part.objects.all(), + label=_('Original Part'), help_text=_('Select original part to duplicate'), + required=True, + ) + + copy_image = serializers.BooleanField( + label=_('Copy Image'), help_text=_('Copy image from original part'), + required=False, default=False, + ) + + copy_bom = serializers.BooleanField( + label=_('Copy BOM'), help_text=_('Copy bill of materials from original part'), + required=False, default=False, + ) + + copy_parameters = serializers.BooleanField( + label=_('Copy Parameters'), help_text=_('Copy parameter data from original part'), + required=False, default=False, + ) + + +class InitialStockSerializer(serializers.Serializer): + """Serializer for creating initial stock quantity.""" + + quantity = serializers.DecimalField( + max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], + label=_('Initial Stock Quantity'), help_text=_('Specify initial stock quantity for this Part. If quantity is zero, no stock is added.'), + required=True, + ) + + location = serializers.PrimaryKeyRelatedField( + queryset=stock.models.StockLocation.objects.all(), + label=_('Initial Stock Location'), help_text=_('Specify initial stock location for this Part'), + allow_null=True, required=False, + ) + + +class InitialSupplierSerializer(serializers.Serializer): + """Serializer for adding initial supplier / manufacturer information""" + + supplier = serializers.PrimaryKeyRelatedField( + queryset=company.models.Company.objects.all(), + label=_('Supplier'), help_text=_('Select supplier (or leave blank to skip)'), + allow_null=True, required=False, + ) + + sku = serializers.CharField( + max_length=100, required=False, allow_blank=True, + label=_('SKU'), help_text=_('Supplier stock keeping unit'), + ) + + manufacturer = serializers.PrimaryKeyRelatedField( + queryset=company.models.Company.objects.all(), + label=_('Manufacturer'), help_text=_('Select manufacturer (or leave blank to skip)'), + allow_null=True, required=False, + ) + + mpn = serializers.CharField( + max_length=100, required=False, allow_blank=True, + label=_('MPN'), help_text=_('Manufacturer part number'), + ) + + def validate_supplier(self, company): + """Validation for the provided Supplier""" + + if company and not company.is_supplier: + raise serializers.ValidationError(_('Selected company is not a valid supplier')) + + return company + + def validate_manufacturer(self, company): + """Validation for the provided Manufacturer""" + + if company and not company.is_manufacturer: + raise serializers.ValidationError(_('Selected company is not a valid manufacturer')) + + return company + + def validate(self, data): + """Extra validation for this serializer""" + + if company.models.ManufacturerPart.objects.filter( + manufacturer=data.get('manufacturer', None), + MPN=data.get('mpn', '') + ).exists(): + raise serializers.ValidationError({ + 'mpn': _('Manufacturer part matching this MPN already exists') + }) + + if company.models.SupplierPart.objects.filter( + supplier=data.get('supplier', None), + SKU=data.get('sku', '') + ).exists(): + raise serializers.ValidationError({ + 'sku': _('Supplier part matching this SKU already exists') + }) + + return data + + class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): """Serializer for complete detail information of a part. @@ -314,6 +424,19 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): """Return the API url associated with this serializer""" return reverse_lazy('api-part-list') + def skip_create_fields(self): + """Skip these fields when instantiating a new Part instance""" + + fields = super().skip_create_fields() + + fields += [ + 'duplicate', + 'initial_stock', + 'initial_supplier', + ] + + return fields + def __init__(self, *args, **kwargs): """Custom initialization method for PartSerializer: @@ -325,6 +448,8 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): parameters = kwargs.pop('parameters', False) + create = kwargs.pop('create', False) + super().__init__(*args, **kwargs) if category_detail is not True: @@ -333,6 +458,11 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): if parameters is not True: self.fields.pop('parameters') + if create is not True: + # These fields are only used for the LIST API endpoint + for f in self.skip_create_fields()[1:]: + self.fields.pop(f) + @staticmethod def annotate_queryset(queryset): """Add some extra annotations to the queryset. @@ -427,6 +557,22 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): read_only=True, ) + # Extra fields used only for creation of a new Part instance + duplicate = DuplicatePartSerializer( + label=_('Duplicate Part'), help_text=_('Copy initial data from another Part'), + write_only=True, required=False + ) + + initial_stock = InitialStockSerializer( + label=_('Initial Stock'), help_text=_('Create Part with initial stock quantity'), + write_only=True, required=False, + ) + + initial_supplier = InitialSupplierSerializer( + label=_('Supplier Information'), help_text=_('Add initial supplier information for this part'), + write_only=True, required=False, + ) + class Meta: """Metaclass defining serializer fields""" model = Part @@ -475,12 +621,83 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer): 'virtual', 'pricing_min', 'pricing_max', + + # Fields only used for Part creation + 'duplicate', + 'initial_stock', + 'initial_supplier', ] read_only_fields = [ 'barcode_hash', ] + @transaction.atomic + def create(self, validated_data): + """Custom method for creating a new Part instance using this serializer""" + + duplicate = validated_data.pop('duplicate', None) + initial_stock = validated_data.pop('initial_stock', None) + initial_supplier = validated_data.pop('initial_supplier', None) + + instance = super().create(validated_data) + + # Copy data from original Part + if duplicate: + original = duplicate['part'] + + if duplicate['copy_bom']: + instance.copy_bom_from(original) + + if duplicate['copy_image']: + instance.image = original.image + instance.save() + + if duplicate['copy_parameters']: + instance.copy_parameters_from(original) + + # Create initial stock entry + if initial_stock: + quantity = initial_stock['quantity'] + location = initial_stock['location'] or instance.default_location + + if quantity > 0: + stockitem = stock.models.StockItem( + part=instance, + quantity=quantity, + location=location, + ) + + stockitem.save(user=self.context['request'].user) + + # Create initial supplier information + if initial_supplier: + + manufacturer = initial_supplier.get('manufacturer', None) + mpn = initial_supplier.get('mpn', '') + + if manufacturer and mpn: + manu_part = company.models.ManufacturerPart.objects.create( + part=instance, + manufacturer=manufacturer, + MPN=mpn + ) + else: + manu_part = None + + supplier = initial_supplier.get('supplier', None) + sku = initial_supplier.get('sku', '') + + if supplier and sku: + company.models.SupplierPart.objects.create( + part=instance, + supplier=supplier, + SKU=sku, + manufacturer_part=manu_part, + ) + + return instance + def save(self): """Save the Part instance""" diff --git a/InvenTree/part/templates/part/category.html b/InvenTree/part/templates/part/category.html index b27ad51560..133a9387a1 100644 --- a/InvenTree/part/templates/part/category.html +++ b/InvenTree/part/templates/part/category.html @@ -336,30 +336,9 @@ {% if roles.part.add %} $("#part-create").click(function() { - - var fields = partFields({ - create: true, + createPart({ + {% if category %}category: {{ category.pk }},{% endif %} }); - - {% if category %} - fields.category.value = {{ category.pk }}; - {% endif %} - - constructForm('{% url "api-part-list" %}', { - method: 'POST', - fields: fields, - groups: partGroups(), - title: '{% trans "Create Part" %}', - reloadFormAfterSuccess: true, - persist: true, - persistMessage: '{% trans "Create another part after this one" %}', - successMessage: '{% trans "Part created successfully" %}', - onSuccess: function(data) { - // Follow the new part - location.href = `/part/${data.pk}/`; - }, - }); - }); {% endif %} diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 4d723a2a86..cf1f132c86 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -12,6 +12,7 @@ from rest_framework import status from rest_framework.test import APIClient import build.models +import company.models import order.models from common.models import InvenTreeSetting from company.models import Company, SupplierPart @@ -544,20 +545,21 @@ class PartOptionsAPITest(InvenTreeAPITestCase): self.assertTrue(sub_part['filters']['component']) -class PartAPITest(InvenTreeAPITestCase): - """Series of tests for the Part DRF API. - - - Tests for Part API - - Tests for PartCategory API - """ +class PartAPITestBase(InvenTreeAPITestCase): + """Base class for running tests on the Part API endpoints""" fixtures = [ 'category', 'part', 'location', 'bom', - 'test_templates', 'company', + 'test_templates', + 'manufacturer_part', + 'params', + 'supplier_part', + 'order', + 'stock', ] roles = [ @@ -568,6 +570,23 @@ class PartAPITest(InvenTreeAPITestCase): 'part_category.add', ] + +class PartAPITest(PartAPITestBase): + """Series of tests for the Part DRF API.""" + + fixtures = [ + 'category', + 'part', + 'location', + 'bom', + 'company', + 'test_templates', + 'manufacturer_part', + 'params', + 'supplier_part', + 'order', + ] + def test_get_categories(self): """Test that we can retrieve list of part categories, with various filtering options.""" url = reverse('api-part-category-list') @@ -873,203 +892,6 @@ class PartAPITest(InvenTreeAPITestCase): self.assertEqual(len(data['results']), n) - def test_default_values(self): - """Tests for 'default' values: - - Ensure that unspecified fields revert to "default" values - (as specified in the model field definition) - """ - url = reverse('api-part-list') - - response = self.post( - url, - { - 'name': 'all defaults', - 'description': 'my test part', - 'category': 1, - }, - expected_code=201, - ) - - data = response.data - - # Check that the un-specified fields have used correct default values - self.assertTrue(data['active']) - self.assertFalse(data['virtual']) - - # By default, parts are purchaseable - self.assertTrue(data['purchaseable']) - - # Set the default 'purchaseable' status to True - InvenTreeSetting.set_setting( - 'PART_PURCHASEABLE', - True, - self.user - ) - - response = self.post( - url, - { - 'name': 'all defaults 2', - 'description': 'my test part 2', - 'category': 1, - }, - expected_code=201, - ) - - # Part should now be purchaseable by default - self.assertTrue(response.data['purchaseable']) - - # "default" values should not be used if the value is specified - response = self.post( - url, - { - 'name': 'all defaults 3', - 'description': 'my test part 3', - 'category': 1, - 'active': False, - 'purchaseable': False, - }, - expected_code=201 - ) - - 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) - - def test_strange_chars(self): - """Test that non-standard ASCII chars are accepted.""" - url = reverse('api-part-list') - - name = "Kaltgerätestecker" - description = "Gerät" - - data = { - "name": name, - "description": description, - "category": 2 - } - - response = self.post(url, data, expected_code=201) - - self.assertEqual(response.data['name'], name) - self.assertEqual(response.data['description'], description) - def test_template_filters(self): """Unit tests for API filters related to template parts: @@ -1295,30 +1117,256 @@ class PartAPITest(InvenTreeAPITestCase): self.assertEqual(part.category.name, row['Category Name']) -class PartDetailTests(InvenTreeAPITestCase): +class PartCreationTests(PartAPITestBase): + """Tests for creating new Part instances via the API""" + + def test_default_values(self): + """Tests for 'default' values: + + Ensure that unspecified fields revert to "default" values + (as specified in the model field definition) + """ + url = reverse('api-part-list') + + response = self.post( + url, + { + 'name': 'all defaults', + 'description': 'my test part', + 'category': 1, + }, + expected_code=201, + ) + + data = response.data + + # Check that the un-specified fields have used correct default values + self.assertTrue(data['active']) + self.assertFalse(data['virtual']) + + # By default, parts are purchaseable + self.assertTrue(data['purchaseable']) + + # Set the default 'purchaseable' status to True + InvenTreeSetting.set_setting( + 'PART_PURCHASEABLE', + True, + self.user + ) + + response = self.post( + url, + { + 'name': 'all defaults 2', + 'description': 'my test part 2', + 'category': 1, + }, + expected_code=201, + ) + + # Part should now be purchaseable by default + self.assertTrue(response.data['purchaseable']) + + # "default" values should not be used if the value is specified + response = self.post( + url, + { + 'name': 'all defaults 3', + 'description': 'my test part 3', + 'category': 1, + 'active': False, + 'purchaseable': False, + }, + expected_code=201 + ) + + self.assertFalse(response.data['active']) + self.assertFalse(response.data['purchaseable']) + + def test_initial_stock(self): + """Tests for initial stock quantity creation.""" + + def submit(stock_data, expected_code=None): + """Helper function for submitting with initial stock data""" + + data = { + 'category': 1, + 'name': "My lil' test part", + 'description': 'A part with which to test', + } + + data['initial_stock'] = stock_data + + response = self.post( + reverse('api-part-list'), + data, + expected_code=expected_code + ) + + return response.data + + # Track how many parts exist at the start of this test + n = Part.objects.count() + + # Submit with empty data + response = submit({}, expected_code=400) + self.assertIn('This field is required', str(response['initial_stock']['quantity'])) + + # Submit with invalid quantity + response = submit({ + 'quantity': 'ax', + }, expected_code=400) + self.assertIn('A valid number is required', str(response['initial_stock']['quantity'])) + + # Submit with valid data + response = submit({ + 'quantity': 50, + 'location': 1, + }, expected_code=201) + + part = Part.objects.get(pk=response['pk']) + self.assertEqual(part.total_stock, 50) + self.assertEqual(n + 1, Part.objects.count()) + + def test_initial_supplier_data(self): + """Tests for initial creation of supplier / manufacturer data.""" + + def submit(supplier_data, expected_code=400): + """Helper function for submitting with supplier data""" + + data = { + 'name': 'My test part', + 'description': 'A test part thingy', + 'category': 1, + } + + data['initial_supplier'] = supplier_data + + response = self.post( + reverse('api-part-list'), + data, + expected_code=expected_code + ) + + return response.data + + n_part = Part.objects.count() + n_mp = company.models.ManufacturerPart.objects.count() + n_sp = company.models.SupplierPart.objects.count() + + # Submit with an invalid manufacturer + response = submit({ + 'manufacturer': 99999, + }) + + self.assertIn('object does not exist', str(response['initial_supplier']['manufacturer'])) + + response = submit({ + 'manufacturer': 8 + }) + + self.assertIn('Selected company is not a valid manufacturer', str(response['initial_supplier']['manufacturer'])) + + # Submit with an invalid supplier + response = submit({ + 'supplier': 8, + }) + + self.assertIn('Selected company is not a valid supplier', str(response['initial_supplier']['supplier'])) + + # Test for duplicate MPN + response = submit({ + 'manufacturer': 6, + 'mpn': 'MPN123', + }) + + self.assertIn('Manufacturer part matching this MPN already exists', str(response)) + + # Test for duplicate SKU + response = submit({ + 'supplier': 2, + 'sku': 'MPN456-APPEL', + }) + + self.assertIn('Supplier part matching this SKU already exists', str(response)) + + # Test fields which are too long + response = submit({ + 'sku': 'abc' * 100, + 'mpn': 'xyz' * 100, + }) + + too_long = 'Ensure this field has no more than 100 characters' + + self.assertIn(too_long, str(response['initial_supplier']['sku'])) + self.assertIn(too_long, str(response['initial_supplier']['mpn'])) + + # Finally, submit a valid set of information + response = submit( + { + 'supplier': 2, + 'sku': 'ABCDEFG', + 'manufacturer': 6, + 'mpn': 'QWERTY' + }, + expected_code=201 + ) + + self.assertEqual(n_part + 1, Part.objects.count()) + self.assertEqual(n_sp + 1, company.models.SupplierPart.objects.count()) + self.assertEqual(n_mp + 1, company.models.ManufacturerPart.objects.count()) + + def test_strange_chars(self): + """Test that non-standard ASCII chars are accepted.""" + url = reverse('api-part-list') + + name = "Kaltgerätestecker" + description = "Gerät" + + data = { + "name": name, + "description": description, + "category": 2 + } + + response = self.post(url, data, expected_code=201) + + self.assertEqual(response.data['name'], name) + self.assertEqual(response.data['description'], description) + + def test_duplication(self): + """Test part duplication options""" + + # Run a matrix of tests + for bom in [True, False]: + for img in [True, False]: + for params in [True, False]: + response = self.post( + reverse('api-part-list'), + { + 'name': f'thing_{bom}{img}{params}', + 'description': 'Some description', + 'category': 1, + 'duplicate': { + 'part': 100, + 'copy_bom': bom, + 'copy_image': img, + 'copy_parameters': params, + } + }, + expected_code=201, + ) + + part = Part.objects.get(pk=response.data['pk']) + + # Check new part + self.assertEqual(part.bom_items.count(), 4 if bom else 0) + self.assertEqual(part.parameters.count(), 2 if params else 0) + + +class PartDetailTests(PartAPITestBase): """Test that we can create / edit / delete Part objects via the API.""" - fixtures = [ - 'category', - 'part', - 'location', - 'bom', - 'company', - 'test_templates', - 'manufacturer_part', - 'supplier_part', - 'order', - 'stock', - ] - - roles = [ - 'part.change', - 'part.add', - 'part.delete', - 'part_category.change', - 'part_category.add', - ] - def test_part_operations(self): """Test that Part instances can be adjusted via the API""" n = Part.objects.count() @@ -2556,7 +2604,7 @@ class PartParameterTest(InvenTreeAPITestCase): response = self.get(url) - self.assertEqual(len(response.data), 5) + self.assertEqual(len(response.data), 7) # Filter by part response = self.get( @@ -2576,7 +2624,7 @@ class PartParameterTest(InvenTreeAPITestCase): } ) - self.assertEqual(len(response.data), 3) + self.assertEqual(len(response.data), 4) def test_create_param(self): """Test that we can create a param via the API.""" @@ -2595,7 +2643,7 @@ class PartParameterTest(InvenTreeAPITestCase): response = self.get(url) - self.assertEqual(len(response.data), 6) + self.assertEqual(len(response.data), 8) def test_param_detail(self): """Tests for the PartParameter detail endpoint.""" diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 64959855f2..644c4fde2a 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -255,10 +255,6 @@ class PartTest(TestCase): self.assertIn('InvenTree', barcode) self.assertIn('"part": {"id": 3}', barcode) - def test_copy(self): - """Test that we can 'deep copy' a Part instance""" - self.r2.deep_copy(self.r1, image=True, bom=True) - def test_sell_pricing(self): """Check that the sell pricebreaks were loaded""" self.assertTrue(self.r1.has_price_breaks) diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index 543d868253..36fb33d52f 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -17,6 +17,7 @@ {% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %} {% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %} {% include "InvenTree/settings/setting.html" with key="PART_CREATE_INITIAL" icon="fa-boxes" %} + {% include "InvenTree/settings/setting.html" with key="PART_CREATE_SUPPLIER" icon="fa-shopping-cart" %} {% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %} {% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %} diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index a2293a6c1d..705eeb7f75 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -429,6 +429,52 @@ function constructForm(url, options) { } +/* + * Extracted information about a 'nested field' from the API metadata: + * + * - Nested fields are designated using a '__' (double underscore) separator + * - Currently only single-depth nesting is supported + */ +function extractNestedField(field_name, fields) { + + var field_path = field_name.split('__'); + var parent_name = field_path[0]; + var child_name = field_path[1]; + + var parent_field = fields[parent_name]; + var child_field = null; + + // Check that the parent field exists + if (!parent_field) { + console.warn(`Expected parent field '${parent_name}' missing from API metadata`); + return; + } + + // Check that the parent field is a 'nested object' + if (parent_field.type != 'nested object') { + console.warn(`Parent field '${parent_name}' is not designated as a nested object`); + return; + } + + // Check that the field has a 'children' attribute + if ('children' in parent_field) { + child_field = parent_field['children'][child_name]; + } else { + console.warn(`Parent field '${parent_name}' missing 'children' field`); + return; + } + + if (child_field) { + // Mark this as a nested child field + child_field['nested_child'] = true; + child_field['parent_name'] = parent_name; + child_field['child_name'] = child_name; + } + + return child_field; +} + + /* * Construct a modal form based on the provided options * @@ -476,10 +522,22 @@ function constructFormBody(fields, options) { } } + // Provide each field object with its own name for (field in fields) { fields[field].name = field; + /* Handle metadata for 'nested' fields. + * - Nested fields are designated using a '__' (double underscore) separator + * - Currently only single depth nesting is supported + */ + if (field.includes('__')) { + var nested_field_info = extractNestedField(field, fields); + + // Update the field data + fields[field] = Object.assign(fields[field], nested_field_info); + } + // If any "instance_filters" are defined for the endpoint, copy them across (overwrite) if (fields[field].instance_filters) { fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters); @@ -802,7 +860,16 @@ function submitFormData(fields, options) { // Normal field (not a file or image) form_data.append(name, value); - data[name] = value; + if (field.parent_name && field.child_name) { + // "Nested" fields are handled a little differently + if (!(field.parent_name in data)) { + data[field.parent_name] = {}; + } + + data[field.parent_name][field.child_name] = value; + } else { + data[name] = value; + } } } else { console.warn(`Could not find field matching '${name}'`); @@ -1171,7 +1238,7 @@ function clearFormErrors(options={}) { * */ -function handleNestedErrors(errors, field_name, options={}) { +function handleNestedArrayErrors(errors, field_name, options={}) { var error_list = errors[field_name]; @@ -1184,7 +1251,7 @@ function handleNestedErrors(errors, field_name, options={}) { // Nest list must be provided! if (!nest_list) { - console.warn(`handleNestedErrors missing nesting options for field '${fieldName}'`); + console.warn(`handleNestedArrayErrors missing nesting options for field '${fieldName}'`); return; } @@ -1193,7 +1260,7 @@ function handleNestedErrors(errors, field_name, options={}) { var error_item = error_list[idx]; if (idx >= nest_list.length) { - console.warn(`handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`); + console.warn(`handleNestedArrayErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`); break; } @@ -1294,16 +1361,27 @@ function handleFormErrors(errors, fields={}, options={}) { for (var field_name in errors) { var field = fields[field_name] || {}; + var field_errors = errors[field_name]; - if ((field.type == 'field') && ('child' in field)) { - // This is a "nested" field - handleNestedErrors(errors, field_name, options); + if ((field.type == 'nested object') && ('children' in field)) { + // Handle multi-level nested errors + + for (var sub_field in field_errors) { + var sub_field_name = `${field_name}__${sub_field}`; + var sub_field_errors = field_errors[sub_field]; + + if (!first_error_field && sub_field_errors && isFieldVisible(sub_field_name, options)) { + first_error_field = sub_field_name; + } + + addFieldErrorMessage(sub_field_name, sub_field_errors, options); + } + } else if ((field.type == 'field') && ('child' in field)) { + // This is a "nested" array field + handleNestedArrayErrors(errors, field_name, options); } else { // This is a "simple" field - - var field_errors = errors[field_name]; - - if (field_errors && !first_error_field && isFieldVisible(field_name, options)) { + if (!first_error_field && field_errors && isFieldVisible(field_name, options)) { first_error_field = field_name; } @@ -1313,9 +1391,15 @@ function handleFormErrors(errors, fields={}, options={}) { if (first_error_field) { // Ensure that the field in question is visible - document.querySelector(`#div_id_${field_name}`).scrollIntoView({ - behavior: 'smooth', - }); + var error_element = document.querySelector(`#div_id_${first_error_field}`); + + if (error_element) { + error_element.scrollIntoView({ + behavior: 'smooth', + }); + } else { + console.warn(`Could not scroll to field '${first_error_field}' - element not found`); + } } else { // Scroll to the top of the form $(options.modal).find('.modal-form-content-wrapper').scrollTop(0); @@ -2058,8 +2142,10 @@ function constructField(name, parameters, options={}) { return constructHiddenInput(field_name, parameters, options); } + var group_name = parameters.group || parameters.parent_name; + // Are we ending a group? - if (options.current_group && parameters.group != options.current_group) { + if (options.current_group && group_name != options.current_group) { html += ``; // Null out the current "group" so we can start a new one @@ -2067,9 +2153,9 @@ function constructField(name, parameters, options={}) { } // Are we starting a new group? - if (parameters.group) { + if (group_name) { - var group = parameters.group; + var group = group_name; var group_id = getFieldName(group, options); @@ -2077,7 +2163,7 @@ function constructField(name, parameters, options={}) { // Are we starting a new group? // Add HTML for the start of a separate panel - if (parameters.group != options.current_group) { + if (group_name != options.current_group) { html += `
@@ -2091,7 +2177,7 @@ function constructField(name, parameters, options={}) { html += `
`; } - html += `

${group_options.title || group}

`; + html += `
${group_options.title || group}
`; if (group_options.collapsible) { html += ``; diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index ec313a9a4d..a3d84ded86 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -20,6 +20,7 @@ */ /* exported + createPart, deletePart, deletePartCategory, duplicateBom, @@ -63,11 +64,16 @@ function partGroups() { title: '{% trans "Part Duplication Options" %}', collapsible: true, }, - supplier: { - title: '{% trans "Supplier Options" %}', + initial_stock: { + title: '{% trans "Initial Stock" %}', collapsible: true, - hidden: !global_settings.PART_PURCHASEABLE, - } + hidden: !global_settings.PART_CREATE_INITIAL, + }, + initial_supplier: { + title: '{% trans "Initial Supplier Data" %}', + collapsible: true, + hidden: !global_settings.PART_CREATE_SUPPLIER, + }, }; } @@ -87,7 +93,7 @@ function partFields(options={}) { }, filters: { structural: false, - } + }, }, name: {}, IPN: {}, @@ -151,6 +157,10 @@ function partFields(options={}) { }, }; + if (options.category) { + fields.category.value = options.category; + } + // If editing a part, we can set the "active" status if (options.edit) { fields.active = { @@ -164,38 +174,33 @@ function partFields(options={}) { } if (options.create || options.duplicate) { + + // Add fields for creating initial supplier data + + // Add fields for creating initial stock 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 "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', + fields.initial_stock__quantity = { + value: 0, }; + fields.initial_stock__location = {}; } - } - // Additional fields when "creating" a new part - if (options.create) { + // Add fields for creating initial supplier data + if (global_settings.PART_CREATE_SUPPLIER) { + fields.initial_supplier__supplier = { + filters: { + is_supplier: true, + } + }; + fields.initial_supplier__sku = {}; + fields.initial_supplier__manufacturer = { + filters: { + is_manufacturer: true, + } + }; + fields.initial_supplier__mpn = {}; + } // No supplier parts available yet delete fields['default_supplier']; @@ -207,87 +212,28 @@ function partFields(options={}) { value: global_settings.PART_CATEGORY_PARAMETERS, group: 'create', }; - - // Supplier options - fields.add_supplier_info = { - type: 'boolean', - label: '{% trans "Add Supplier Data" %}', - help_text: '{% trans "Create initial supplier data for this part" %}', - group: 'supplier', - }; - - fields.supplier = { - type: 'related field', - model: 'company', - label: '{% trans "Supplier" %}', - help_text: '{% trans "Select supplier" %}', - filters: { - 'is_supplier': true, - }, - api_url: '{% url "api-company-list" %}', - group: 'supplier', - }; - - fields.SKU = { - type: 'string', - label: '{% trans "SKU" %}', - help_text: '{% trans "Supplier stock keeping unit" %}', - group: 'supplier', - }; - - fields.manufacturer = { - type: 'related field', - model: 'company', - label: '{% trans "Manufacturer" %}', - help_text: '{% trans "Select manufacturer" %}', - filters: { - 'is_manufacturer': true, - }, - api_url: '{% url "api-company-list" %}', - group: 'supplier', - }; - - fields.MPN = { - type: 'string', - label: '{% trans "MPN" %}', - help_text: '{% trans "Manufacturer Part Number" %}', - group: 'supplier', - }; - } // Additional fields when "duplicating" a part if (options.duplicate) { - fields.copy_from = { - type: 'integer', - hidden: true, + // The following fields exist under the child serializer named 'duplicate' + + fields.duplicate__part = { value: options.duplicate, - group: 'duplicate', - }, - - fields.copy_image = { - type: 'boolean', - label: '{% trans "Copy Image" %}', - help_text: '{% trans "Copy image from original part" %}', - value: true, - group: 'duplicate', - }, - - fields.copy_bom = { - type: 'boolean', - label: '{% trans "Copy BOM" %}', - help_text: '{% trans "Copy bill of materials from original part" %}', - value: global_settings.PART_COPY_BOM, - group: 'duplicate', + hidden: true, }; - fields.copy_parameters = { - type: 'boolean', - label: '{% trans "Copy Parameters" %}', - help_text: '{% trans "Copy parameter data from original part" %}', + fields.duplicate__copy_image = { + value: true, + }; + + fields.duplicate__copy_bom = { + value: global_settings.PART_COPY_BOM, + }; + + fields.duplicate__copy_parameters = { value: global_settings.PART_COPY_PARAMETERS, - group: 'duplicate', }; } @@ -295,6 +241,9 @@ function partFields(options={}) { } +/* + * Construct a set of fields for a PartCategory intance + */ function categoryFields() { return { parent: { @@ -378,6 +327,32 @@ function deletePartCategory(pk, options={}) { } +/* + * Launches a form to create a new Part instance + */ +function createPart(options={}) { + + options.create = true; + + constructForm('{% url "api-part-list" %}', { + method: 'POST', + fields: partFields(options), + groups: partGroups(), + title: '{% trans "Create Part" %}', + reloadFormAfterSuccess: true, + persistMessage: '{% trans "Create another part after this one" %}', + successMessage: '{% trans "Part created successfully" %}', + onSuccess: function(data) { + // Follow the new part + location.href = `/part/${data.pk}/`; + }, + }); +} + + +/* + * Launches a form to edit an existing Part instance + */ function editPart(pk) { var url = `/api/part/${pk}/`;