mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +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:
		@@ -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)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user