mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
c6df0dbb2d
commit
4f029d4d81
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
|
@ -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'))
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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"""
|
||||
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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)
|
||||
|
@ -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" %}
|
||||
<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_ASSEMBLY" icon="fa-tools" %}
|
||||
|
@ -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 += `</div></div>`;
|
||||
|
||||
// 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 += `
|
||||
<div class='panel form-panel' id='form-panel-${group_id}' group='${group}'>
|
||||
@ -2091,7 +2177,7 @@ function constructField(name, parameters, options={}) {
|
||||
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) {
|
||||
html += `</a>`;
|
||||
|
@ -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}/`;
|
||||
|
Loading…
Reference in New Issue
Block a user