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