Improvements for part creation API endpoint (#4281)

* Refactor javascript for creating a new part

* Simplify method of removing create fields from serializer

* Fix bug which resulted in multiple model instances being created

* remove custom code required on Part model

* Reorganize existing Part API test code

* Add child serializer for part duplication options

* Part duplication is now handled by the DRF serializer

- Improved validation options
- API is self-documenting (no more secret fields)
- More DRY

* Initial stock is now handled by the DRF serializer

* Adds child serializer for adding initial supplier data for a Part instance

* Create initial supplier and manufacturer parts as specified

* Adding unit tests

* Add unit tests for part duplication via API

* Bump API version

* Add javascript for automatically extracting info for nested fields

* Improvements for part creation form rendering

- Move to nested fields (using API metadata)
- Visual improvements
- Improve some field name / description values

* Properly format nested fields for sending to the server

* Handle error case for scrollIntoView

* Display errors for nested fields

* Fix bug for filling part category

* JS linting fixes

* Unit test fixes

* Fixes for unit tests

* Further fixes to unit tests
This commit is contained in:
Oliver 2023-02-02 09:24:16 +11:00 committed by GitHub
parent c6df0dbb2d
commit 4f029d4d81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 770 additions and 585 deletions

View File

@ -2,17 +2,24 @@
# InvenTree API version # 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 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 v90 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4186/files
- Adds a dedicated endpoint to activate a plugin - Adds a dedicated endpoint to activate a plugin
v89 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4214 v89 -> 2023-01-25 : https://github.com/inventree/InvenTree/pull/4214
- Adds updated field to SupplierPart API - Adds updated field to SupplierPart API
- Adds API date orddering for supplier part list - Adds API date orddering for supplier part list
v88 -> 2023-01-17: https://github.com/inventree/InvenTree/pull/4225 v88 -> 2023-01-17: https://github.com/inventree/InvenTree/pull/4225
- Adds 'priority' field to Build model and api endpoints - Adds 'priority' field to Build model and api endpoints
v87 -> 2023-01-04 : https://github.com/inventree/InvenTree/pull/4067 v87 -> 2023-01-04 : https://github.com/inventree/InvenTree/pull/4067
- Add API date filter for stock table on Expiry date - Add API date filter for stock table on Expiry date

View File

@ -147,6 +147,16 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return initials 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): def save(self, **kwargs):
"""Catch any django ValidationError thrown at the moment `save` is called, and re-throw as a DRF ValidationError.""" """Catch any django ValidationError thrown at the moment `save` is called, and re-throw as a DRF ValidationError."""
try: try:
@ -156,6 +166,17 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
return self.instance 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): def update(self, instance, validated_data):
"""Catch any django ValidationError, and re-throw as a DRF ValidationError.""" """Catch any django ValidationError, and re-throw as a DRF ValidationError."""
try: try:
@ -171,14 +192,21 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
In addition to running validators on the serializer fields, In addition to running validators on the serializer fields,
this class ensures that the underlying model is also validated. this class ensures that the underlying model is also validated.
""" """
# Run any native validation checks first (may raise a ValidationError) # Run any native validation checks first (may raise a ValidationError)
data = super().run_validation(data) data = super().run_validation(data)
# Now ensure the underlying model is correct
if not hasattr(self, 'instance') or self.instance is None: if not hasattr(self, 'instance') or self.instance is None:
# No instance exists (we are creating a new one) # 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: else:
# Instance already exists (we are updating!) # Instance already exists (we are updating!)
instance = self.instance instance = self.instance
@ -599,6 +627,13 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
Adds the optional, write-only `remote_image` field to the serializer 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( remote_image = serializers.URLField(
required=False, required=False,
allow_blank=False, allow_blank=False,

View File

@ -1121,12 +1121,19 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'PART_CREATE_INITIAL': { 'PART_CREATE_INITIAL': {
'name': _('Create initial stock'), 'name': _('Initial Stock Data'),
'description': _('Create initial stock on part creation'), 'description': _('Allow creation of initial stock when adding a new part'),
'default': False, 'default': False,
'validator': bool, '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': { 'PART_NAME_FORMAT': {
'name': _('Part Name Display Format'), 'name': _('Part Name Display Format'),
'description': _('Format to display the part name'), 'description': _('Format to display the part name'),

View File

@ -46,3 +46,12 @@
name: Another manufacturer name: Another manufacturer
description: They build things and sell it to us description: They build things and sell it to us
is_manufacturer: True 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

View File

@ -94,17 +94,6 @@ class Company(MetadataMixin, models.Model):
] ]
verbose_name_plural = "Companies" 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, name = models.CharField(max_length=100, blank=False,
help_text=_('Company name'), help_text=_('Company name'),
verbose_name=_('Company name')) verbose_name=_('Company name'))

View File

@ -1,7 +1,6 @@
"""Provides a JSON API for the Part app.""" """Provides a JSON API for the Part app."""
import functools import functools
from decimal import Decimal, InvalidOperation
from django.db import transaction from django.db import transaction
from django.db.models import Count, F, Q from django.db.models import Count, F, Q
@ -18,7 +17,6 @@ from rest_framework.response import Response
import order.models import order.models
from build.models import Build, BuildItem from build.models import Build, BuildItem
from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.api import (APIDownloadMixin, AttachmentMixin, from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView) ListCreateDestroyAPIView)
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
@ -33,7 +31,6 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus) SalesOrderStatus)
from part.admin import PartCategoryResource, PartResource from part.admin import PartCategoryResource, PartResource
from plugin.serializers import MetadataSerializer from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockLocation
from . import serializers as part_serializers from . import serializers as part_serializers
from . import views from . import views
@ -1096,25 +1093,7 @@ class PartFilter(rest_filters.FilterSet):
class PartList(APIDownloadMixin, ListCreateAPI): class PartList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Part objects. """API endpoint for accessing a list of Part objects, or creating a new Part instance"""
- 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)
"""
serializer_class = part_serializers.PartSerializer serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all() queryset = Part.objects.all()
@ -1127,6 +1106,9 @@ class PartList(APIDownloadMixin, ListCreateAPI):
# Ensure the request context is passed through # Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context() 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 # Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required! # We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None: 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) 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): def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file""" """Download the filtered queryset as a data file"""
dataset = PartResource().export(queryset=queryset) dataset = PartResource().export(queryset=queryset)
@ -1241,127 +1230,6 @@ class PartList(APIDownloadMixin, ListCreateAPI):
part.save(**{'add_category_templates': copy_templates}) 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) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@ -54,6 +54,20 @@
template: 3 template: 3
data: 12 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) # Add some template parameters to categories (requires category.yaml)
- model: part.PartCategoryParameterTemplate - model: part.PartCategoryParameterTemplate
pk: 1 pk: 1

View File

@ -391,17 +391,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
parent_attr = 'variant_of' 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 @staticmethod
def get_api_url(): def get_api_url():
"""Return the list API endpoint URL associated with the Part model""" """Return the list API endpoint URL associated with the Part model"""
@ -2034,41 +2023,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
parameter.save() 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): def getTestTemplates(self, required=None, include_parent=True):
"""Return a list of all test templates associated with this Part. """Return a list of all test templates associated with this Part.

View File

@ -5,6 +5,7 @@ import io
from decimal import Decimal from decimal import Decimal
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, FloatField, Q from django.db.models import ExpressionWrapper, F, FloatField, Q
from django.db.models.functions import Coalesce 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 rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
import company.models
import InvenTree.helpers import InvenTree.helpers
import part.filters import part.filters
import stock.models
from common.settings import currency_code_default, currency_code_mappings from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileExtractSerializer, from InvenTree.serializers import (DataFileExtractSerializer,
DataFileUploadSerializer, 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): class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for complete detail information of a part. """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 the API url associated with this serializer"""
return reverse_lazy('api-part-list') 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): def __init__(self, *args, **kwargs):
"""Custom initialization method for PartSerializer: """Custom initialization method for PartSerializer:
@ -325,6 +448,8 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
parameters = kwargs.pop('parameters', False) parameters = kwargs.pop('parameters', False)
create = kwargs.pop('create', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if category_detail is not True: if category_detail is not True:
@ -333,6 +458,11 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
if parameters is not True: if parameters is not True:
self.fields.pop('parameters') 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 @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add some extra annotations to the queryset. """Add some extra annotations to the queryset.
@ -427,6 +557,22 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
read_only=True, 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: class Meta:
"""Metaclass defining serializer fields""" """Metaclass defining serializer fields"""
model = Part model = Part
@ -475,12 +621,83 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'virtual', 'virtual',
'pricing_min', 'pricing_min',
'pricing_max', 'pricing_max',
# Fields only used for Part creation
'duplicate',
'initial_stock',
'initial_supplier',
] ]
read_only_fields = [ read_only_fields = [
'barcode_hash', '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): def save(self):
"""Save the Part instance""" """Save the Part instance"""

View File

@ -336,30 +336,9 @@
{% if roles.part.add %} {% if roles.part.add %}
$("#part-create").click(function() { $("#part-create").click(function() {
createPart({
var fields = partFields({ {% if category %}category: {{ category.pk }},{% endif %}
create: true,
}); });
{% 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 %} {% endif %}

View File

@ -12,6 +12,7 @@ from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
import build.models import build.models
import company.models
import order.models import order.models
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
@ -544,20 +545,21 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
self.assertTrue(sub_part['filters']['component']) self.assertTrue(sub_part['filters']['component'])
class PartAPITest(InvenTreeAPITestCase): class PartAPITestBase(InvenTreeAPITestCase):
"""Series of tests for the Part DRF API. """Base class for running tests on the Part API endpoints"""
- Tests for Part API
- Tests for PartCategory API
"""
fixtures = [ fixtures = [
'category', 'category',
'part', 'part',
'location', 'location',
'bom', 'bom',
'test_templates',
'company', 'company',
'test_templates',
'manufacturer_part',
'params',
'supplier_part',
'order',
'stock',
] ]
roles = [ roles = [
@ -568,6 +570,23 @@ class PartAPITest(InvenTreeAPITestCase):
'part_category.add', '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): def test_get_categories(self):
"""Test that we can retrieve list of part categories, with various filtering options.""" """Test that we can retrieve list of part categories, with various filtering options."""
url = reverse('api-part-category-list') url = reverse('api-part-category-list')
@ -873,203 +892,6 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(len(data['results']), n) 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): def test_template_filters(self):
"""Unit tests for API filters related to template parts: """Unit tests for API filters related to template parts:
@ -1295,30 +1117,256 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(part.category.name, row['Category Name']) 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.""" """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): def test_part_operations(self):
"""Test that Part instances can be adjusted via the API""" """Test that Part instances can be adjusted via the API"""
n = Part.objects.count() n = Part.objects.count()
@ -2556,7 +2604,7 @@ class PartParameterTest(InvenTreeAPITestCase):
response = self.get(url) response = self.get(url)
self.assertEqual(len(response.data), 5) self.assertEqual(len(response.data), 7)
# Filter by part # Filter by part
response = self.get( 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): def test_create_param(self):
"""Test that we can create a param via the API.""" """Test that we can create a param via the API."""
@ -2595,7 +2643,7 @@ class PartParameterTest(InvenTreeAPITestCase):
response = self.get(url) response = self.get(url)
self.assertEqual(len(response.data), 6) self.assertEqual(len(response.data), 8)
def test_param_detail(self): def test_param_detail(self):
"""Tests for the PartParameter detail endpoint.""" """Tests for the PartParameter detail endpoint."""

View File

@ -255,10 +255,6 @@ class PartTest(TestCase):
self.assertIn('InvenTree', barcode) self.assertIn('InvenTree', barcode)
self.assertIn('"part": {"id": 3}', 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): def test_sell_pricing(self):
"""Check that the sell pricebreaks were loaded""" """Check that the sell pricebreaks were loaded"""
self.assertTrue(self.r1.has_price_breaks) self.assertTrue(self.r1.has_price_breaks)

View File

@ -17,6 +17,7 @@
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %} {% 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_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_INITIAL" icon="fa-boxes" %}
{% include "InvenTree/settings/setting.html" with key="PART_CREATE_SUPPLIER" icon="fa-shopping-cart" %}
<tr><td colspan='5'></td></tr> <tr><td colspan='5'></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %} {% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
{% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %} {% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %}

View File

@ -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 * 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 // Provide each field object with its own name
for (field in fields) { for (field in fields) {
fields[field].name = field; 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 any "instance_filters" are defined for the endpoint, copy them across (overwrite)
if (fields[field].instance_filters) { if (fields[field].instance_filters) {
fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters); fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters);
@ -802,8 +860,17 @@ function submitFormData(fields, options) {
// Normal field (not a file or image) // Normal field (not a file or image)
form_data.append(name, value); form_data.append(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; data[name] = value;
} }
}
} else { } else {
console.warn(`Could not find field matching '${name}'`); 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]; var error_list = errors[field_name];
@ -1184,7 +1251,7 @@ function handleNestedErrors(errors, field_name, options={}) {
// Nest list must be provided! // Nest list must be provided!
if (!nest_list) { if (!nest_list) {
console.warn(`handleNestedErrors missing nesting options for field '${fieldName}'`); console.warn(`handleNestedArrayErrors missing nesting options for field '${fieldName}'`);
return; return;
} }
@ -1193,7 +1260,7 @@ function handleNestedErrors(errors, field_name, options={}) {
var error_item = error_list[idx]; var error_item = error_list[idx];
if (idx >= nest_list.length) { 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; break;
} }
@ -1294,16 +1361,27 @@ function handleFormErrors(errors, fields={}, options={}) {
for (var field_name in errors) { for (var field_name in errors) {
var field = fields[field_name] || {}; var field = fields[field_name] || {};
if ((field.type == 'field') && ('child' in field)) {
// This is a "nested" field
handleNestedErrors(errors, field_name, options);
} else {
// This is a "simple" field
var field_errors = errors[field_name]; var field_errors = errors[field_name];
if (field_errors && !first_error_field && isFieldVisible(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
if (!first_error_field && field_errors && isFieldVisible(field_name, options)) {
first_error_field = field_name; first_error_field = field_name;
} }
@ -1313,9 +1391,15 @@ function handleFormErrors(errors, fields={}, options={}) {
if (first_error_field) { if (first_error_field) {
// Ensure that the field in question is visible // Ensure that the field in question is visible
document.querySelector(`#div_id_${field_name}`).scrollIntoView({ var error_element = document.querySelector(`#div_id_${first_error_field}`);
if (error_element) {
error_element.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
}); });
} else {
console.warn(`Could not scroll to field '${first_error_field}' - element not found`);
}
} else { } else {
// Scroll to the top of the form // Scroll to the top of the form
$(options.modal).find('.modal-form-content-wrapper').scrollTop(0); $(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
@ -2058,8 +2142,10 @@ function constructField(name, parameters, options={}) {
return constructHiddenInput(field_name, parameters, options); return constructHiddenInput(field_name, parameters, options);
} }
var group_name = parameters.group || parameters.parent_name;
// Are we ending a group? // Are we ending a group?
if (options.current_group && parameters.group != options.current_group) { if (options.current_group && group_name != options.current_group) {
html += `</div></div>`; html += `</div></div>`;
// Null out the current "group" so we can start a new one // 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? // 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); var group_id = getFieldName(group, options);
@ -2077,7 +2163,7 @@ function constructField(name, parameters, options={}) {
// Are we starting a new group? // Are we starting a new group?
// Add HTML for the start of a separate panel // Add HTML for the start of a separate panel
if (parameters.group != options.current_group) { if (group_name != options.current_group) {
html += ` html += `
<div class='panel form-panel' id='form-panel-${group_id}' group='${group}'> <div class='panel form-panel' id='form-panel-${group_id}' group='${group}'>
@ -2091,7 +2177,7 @@ function constructField(name, parameters, options={}) {
html += `<div>`; html += `<div>`;
} }
html += `<h4 style='display: inline;'>${group_options.title || group}</h4>`; html += `<h5 style='display: inline;'>${group_options.title || group}</h5>`;
if (group_options.collapsible) { if (group_options.collapsible) {
html += `</a>`; html += `</a>`;

View File

@ -20,6 +20,7 @@
*/ */
/* exported /* exported
createPart,
deletePart, deletePart,
deletePartCategory, deletePartCategory,
duplicateBom, duplicateBom,
@ -63,11 +64,16 @@ function partGroups() {
title: '{% trans "Part Duplication Options" %}', title: '{% trans "Part Duplication Options" %}',
collapsible: true, collapsible: true,
}, },
supplier: { initial_stock: {
title: '{% trans "Supplier Options" %}', title: '{% trans "Initial Stock" %}',
collapsible: true, 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: { filters: {
structural: false, structural: false,
} },
}, },
name: {}, name: {},
IPN: {}, 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 editing a part, we can set the "active" status
if (options.edit) { if (options.edit) {
fields.active = { fields.active = {
@ -164,38 +174,33 @@ function partFields(options={}) {
} }
if (options.create || options.duplicate) { if (options.create || options.duplicate) {
// Add fields for creating initial supplier data
// Add fields for creating initial stock
if (global_settings.PART_CREATE_INITIAL) { if (global_settings.PART_CREATE_INITIAL) {
fields.initial_stock = { fields.initial_stock__quantity = {
type: 'boolean', value: 0,
label: '{% trans "Create Initial Stock" %}',
help_text: '{% trans "Create an initial stock item for this part" %}',
group: 'create',
}; };
fields.initial_stock__location = {};
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',
};
}
} }
// Additional fields when "creating" a new part // Add fields for creating initial supplier data
if (options.create) { 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 // No supplier parts available yet
delete fields['default_supplier']; delete fields['default_supplier'];
@ -207,87 +212,28 @@ function partFields(options={}) {
value: global_settings.PART_CATEGORY_PARAMETERS, value: global_settings.PART_CATEGORY_PARAMETERS,
group: 'create', group: 'create',
}; };
// Supplier options
fields.add_supplier_info = {
type: 'boolean',
label: '{% trans "Add Supplier Data" %}',
help_text: '{% trans "Create initial supplier data for this part" %}',
group: 'supplier',
};
fields.supplier = {
type: 'related field',
model: 'company',
label: '{% trans "Supplier" %}',
help_text: '{% trans "Select supplier" %}',
filters: {
'is_supplier': true,
},
api_url: '{% url "api-company-list" %}',
group: 'supplier',
};
fields.SKU = {
type: 'string',
label: '{% trans "SKU" %}',
help_text: '{% trans "Supplier stock keeping unit" %}',
group: 'supplier',
};
fields.manufacturer = {
type: 'related field',
model: 'company',
label: '{% trans "Manufacturer" %}',
help_text: '{% trans "Select manufacturer" %}',
filters: {
'is_manufacturer': true,
},
api_url: '{% url "api-company-list" %}',
group: 'supplier',
};
fields.MPN = {
type: 'string',
label: '{% trans "MPN" %}',
help_text: '{% trans "Manufacturer Part Number" %}',
group: 'supplier',
};
} }
// Additional fields when "duplicating" a part // Additional fields when "duplicating" a part
if (options.duplicate) { if (options.duplicate) {
fields.copy_from = { // The following fields exist under the child serializer named 'duplicate'
type: 'integer',
hidden: true, fields.duplicate__part = {
value: options.duplicate, value: options.duplicate,
group: 'duplicate', hidden: true,
},
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',
}; };
fields.copy_parameters = { fields.duplicate__copy_image = {
type: 'boolean', value: true,
label: '{% trans "Copy Parameters" %}', };
help_text: '{% trans "Copy parameter data from original part" %}',
fields.duplicate__copy_bom = {
value: global_settings.PART_COPY_BOM,
};
fields.duplicate__copy_parameters = {
value: global_settings.PART_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() { function categoryFields() {
return { return {
parent: { 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) { function editPart(pk) {
var url = `/api/part/${pk}/`; var url = `/api/part/${pk}/`;