From 15c64d6695b1553c6217c16c262e7d90ee689192 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 3 Jul 2026 18:04:59 +1000 Subject: [PATCH] [API] Refactor API for duplicating objects (#12294) * Implement generic serializer for custom model duplication * Apply pattern to BuildOrder * Add more generic options * Bump API version * Adjust default option * Refactor existing implementations * Dynamic class typing * Add duplicate field to more model types - Company - ManufacturerPart - SupplierPart - SalesOrderShipment * Implement parameter duplication for more models: - Company - ManufacturerPart - SupplierPart * Simplify code * Refactor --- .../InvenTree/InvenTree/api_version.py | 7 +- .../InvenTree/InvenTree/serializers.py | 103 ++++++++++++++- src/backend/InvenTree/build/serializers.py | 21 ++++ src/backend/InvenTree/company/serializers.py | 60 +++++++++ src/backend/InvenTree/data_exporter/mixins.py | 9 ++ src/backend/InvenTree/importer/mixins.py | 10 ++ src/backend/InvenTree/order/serializers.py | 119 +++++++++--------- src/backend/InvenTree/order/test_api.py | 10 +- src/backend/InvenTree/part/serializers.py | 97 +++++--------- src/backend/InvenTree/part/test_api.py | 2 +- src/frontend/src/forms/BuildForms.tsx | 25 +++- src/frontend/src/forms/CommonFields.tsx | 20 +++ src/frontend/src/forms/CompanyForms.tsx | 68 ++++++++-- src/frontend/src/forms/PartForms.tsx | 2 +- src/frontend/src/forms/PurchaseOrderForms.tsx | 2 +- src/frontend/src/forms/ReturnOrderForms.tsx | 7 +- src/frontend/src/forms/SalesOrderForms.tsx | 2 +- src/frontend/src/forms/TransferOrderForms.tsx | 5 +- src/frontend/src/pages/build/BuildDetail.tsx | 1 + .../src/pages/company/CompanyDetail.tsx | 21 +++- .../pages/company/ManufacturerPartDetail.tsx | 6 +- .../src/pages/company/SupplierPartDetail.tsx | 6 +- src/frontend/src/tables/part/PartTable.tsx | 12 +- .../purchasing/ManufacturerPartTable.tsx | 6 +- .../tables/purchasing/SupplierPartTable.tsx | 6 +- 25 files changed, 458 insertions(+), 169 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index b34c1ef5c5..26ce0c47d7 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 513 +INVENTREE_API_VERSION = 514 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v514 -> 2026-07-02 : https://github.com/inventree/InvenTree/pull/12294 + - Adds "duplicate" field to the BuildOrder, Company, ManufacturerPart, SupplierPart and SalesOrderShipment API endpoints + - Order duplication options: renames "order_id" field to "original", which now performs primary-key validation + - Part duplication options: renames "part" field to "original" + v513 -> 2026-06-25 : https://github.com/inventree/InvenTree/pull/12250 - Adds "active" field to the ProjectCode model and API endpoints diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index ba75e875d8..a187d73e51 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -600,7 +600,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): Default implementation returns an empty list """ - return [] + return getattr(self, 'SKIP_CREATE_FIELDS', []) def save(self, **kwargs): """Catch any django ValidationError thrown at the moment `save` is called, and re-throw as a DRF ValidationError.""" @@ -921,3 +921,104 @@ class ContentTypeField(serializers.ChoiceField): ) return content_type + + +class DuplicateOptionsSerializer(serializers.Serializer): + """Generic serializer for specifying copy options when duplicating a model instance. + + Builds its fields dynamically at instantiation time so the same class can be + reused for any model without subclassing. + """ + + # Special 'shortcut' fields which are used for multiple models + DEFAULT_FIELDS = [ + ( + 'copy_parameters', + _('Copy Parameters'), + _('Copy parameters from the original item'), + False, + ), + ( + 'copy_lines', + _('Copy Lines'), + _('Copy line items from the original order'), + False, + ), + ( + 'copy_extra_lines', + _('Copy Extra Lines'), + _('Copy extra line items from the original order'), + False, + ), + ] + + def __init__( + self, + queryset: QuerySet, + *args, + copy_fields: Optional[list[dict]] = None, + **kwargs, + ): + """Initialise the serializer and dynamically attach fields. + + Arguments: + queryset: Queryset used for the `original` PrimaryKeyRelatedField. + copy_fields: Optional list of dicts, each describing one boolean copy toggle. + Keys: + name: (str, required) + label: (str, optional) + help_text: (str, optional) + default: (bool, optional, default True) + """ + # Enforce certain properties onto this serializer + kwargs['label'] = kwargs.get('label', _('Duplication Options')) + kwargs['help_text'] = kwargs.get( + 'help_text', _('Specify options for duplicating this item') + ) + kwargs['required'] = False + kwargs['write_only'] = True + + copy_fields = copy_fields or [] + copy_field_names = [spec['name'] for spec in copy_fields] + + # Apply "default" fields + for name, label, help_text, default_value in self.DEFAULT_FIELDS: + popped_value = kwargs.pop(name, default_value) + + if name in copy_field_names: + # Manually supplied field, continue + continue + + if popped_value: + copy_fields.append({ + 'name': name, + 'label': label, + 'help_text': help_text, + 'default': True, + }) + + super().__init__(*args, **kwargs) + + # Re-class the instance with a model-specific subclass, + # so that each model generates a unique schema component name + if self.__class__ is DuplicateOptionsSerializer: + self.__class__ = type( + f'{queryset.model.__name__}DuplicateOptionsSerializer', + (DuplicateOptionsSerializer,), + {}, + ) + + self.fields['original'] = serializers.PrimaryKeyRelatedField( + queryset=queryset, + required=True, + label=_('Original'), + help_text=_('Select instance to duplicate'), + ) + + for spec in copy_fields or []: + self.fields[spec['name']] = serializers.BooleanField( + required=False, + default=spec.get('default', True), + label=spec.get('label', spec['name']), + help_text=spec.get('help_text', ''), + ) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index a4bf2430fd..3864061b7d 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -34,6 +34,7 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( CustomStatusSerializerMixin, + DuplicateOptionsSerializer, FilterableSerializerMixin, InvenTreeDecimalField, InvenTreeModelSerializer, @@ -67,6 +68,8 @@ class BuildSerializer( ): """Serializes a Build object.""" + SKIP_CREATE_FIELDS = ['duplicate'] + class Meta: """Serializer metaclass.""" @@ -80,6 +83,7 @@ class BuildSerializer( 'completed', 'completion_date', 'destination', + 'duplicate', 'external', 'parent', 'part', @@ -190,12 +194,29 @@ class BuildSerializer( return queryset + duplicate = DuplicateOptionsSerializer(Build.objects.all(), copy_parameters=True) + def __init__(self, *args, **kwargs): """Determine if extra serializer fields are required.""" kwargs.pop('create', False) super().__init__(*args, **kwargs) + @transaction.atomic + def create(self, validated_data): + """Create a new Build instance, optionally copying data from an existing build.""" + duplicate = validated_data.pop('duplicate', None) + + instance = super().create(validated_data) + + if duplicate: + original = duplicate['original'] + + if duplicate.get('copy_parameters', True): + instance.copy_parameters_from(original) + + return instance + def validate_reference(self, reference): """Custom validation for the Build reference field.""" # Ensure the reference matches the required pattern diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 1aa191e966..809f06710f 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -1,5 +1,6 @@ """JSON serializers for Company app.""" +from django.db import transaction from django.db.models import Prefetch from django.utils.translation import gettext_lazy as _ @@ -14,6 +15,7 @@ from importer.registry import register_importer from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import ( + DuplicateOptionsSerializer, FilterableSerializerMixin, InvenTreeCurrencySerializer, InvenTreeDecimalField, @@ -118,6 +120,8 @@ class CompanySerializer( import_exclude_fields = ['image'] + SKIP_CREATE_FIELDS = ['duplicate'] + class Meta: """Metaclass options.""" @@ -132,6 +136,7 @@ class CompanySerializer( 'email', 'currency', 'contact', + 'duplicate', 'link', 'image', 'active', @@ -191,6 +196,23 @@ class CompanySerializer( parameters = common.filters.enable_parameters_filter() + duplicate = DuplicateOptionsSerializer(Company.objects.all(), copy_parameters=True) + + @transaction.atomic + def create(self, validated_data): + """Create a new Company instance, optionally copying data from an existing company.""" + duplicate = validated_data.pop('duplicate', None) + + instance = super().create(validated_data) + + if duplicate: + original = duplicate['original'] + + if duplicate.get('copy_parameters', True): + instance.copy_parameters_from(original) + + return instance + @register_importer() class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): @@ -217,6 +239,8 @@ class ManufacturerPartSerializer( ): """Serializer for ManufacturerPart object.""" + SKIP_CREATE_FIELDS = ['duplicate'] + class Meta: """Metaclass options.""" @@ -229,6 +253,7 @@ class ManufacturerPartSerializer( 'manufacturer', 'manufacturer_detail', 'description', + 'duplicate', 'MPN', 'link', 'barcode_hash', @@ -241,6 +266,25 @@ class ManufacturerPartSerializer( parameters = common.filters.enable_parameters_filter() + duplicate = DuplicateOptionsSerializer( + ManufacturerPart.objects.all(), copy_parameters=True + ) + + @transaction.atomic + def create(self, validated_data): + """Create a new ManufacturerPart instance, optionally copying data from an existing instance.""" + duplicate = validated_data.pop('duplicate', None) + + instance = super().create(validated_data) + + if duplicate: + original = duplicate['original'] + + if duplicate.get('copy_parameters', True): + instance.copy_parameters_from(original) + + return instance + part_detail = OptionalField( serializer_class=part_serializers.PartBriefSerializer, serializer_kwargs={ @@ -323,6 +367,8 @@ class SupplierPartSerializer( export_exclude_fields = ['tags'] + SKIP_CREATE_FIELDS = ['duplicate'] + export_child_fields = [ 'part_detail.name', 'part_detail.description', @@ -339,6 +385,7 @@ class SupplierPartSerializer( 'available', 'availability_updated', 'description', + 'duplicate', 'in_stock', 'on_order', 'link', @@ -494,6 +541,10 @@ class SupplierPartSerializer( # Date fields updated = serializers.DateTimeField(allow_null=True, read_only=True) + duplicate = DuplicateOptionsSerializer( + SupplierPart.objects.all(), copy_parameters=True + ) + @staticmethod def annotate_queryset(queryset): """Annotate the SupplierPart queryset with extra fields. @@ -522,8 +573,11 @@ class SupplierPartSerializer( return response + @transaction.atomic def create(self, validated_data): """Extract manufacturer data and process ManufacturerPart.""" + duplicate = validated_data.pop('duplicate', None) + # Extract 'available' quantity from the serializer available = validated_data.pop('available', None) @@ -541,6 +595,12 @@ class SupplierPartSerializer( kwargs = {'manufacturer': manufacturer, 'MPN': MPN} supplier_part.save(**kwargs) + if duplicate: + original = duplicate['original'] + + if duplicate.get('copy_parameters', True): + supplier_part.copy_parameters_from(original) + return supplier_part diff --git a/src/backend/InvenTree/data_exporter/mixins.py b/src/backend/InvenTree/data_exporter/mixins.py index 0d58d0efff..af1b971db8 100644 --- a/src/backend/InvenTree/data_exporter/mixins.py +++ b/src/backend/InvenTree/data_exporter/mixins.py @@ -16,6 +16,7 @@ from taggit.serializers import TagListSerializerField import data_exporter.serializers import data_exporter.tasks import InvenTree.exceptions +import InvenTree.serializers from common.models import DataOutput from InvenTree.helpers import str2bool from InvenTree.tasks import offload_task @@ -64,6 +65,14 @@ class DataExportSerializerMixin: # Exclude fields which are not required for data export for field in self.get_export_exclude_fields(**kwargs): self.fields.pop(field, None) + + # Duplication options are never used for data export + for field in [ + name + for name, field in self.fields.items() + if isinstance(field, InvenTree.serializers.DuplicateOptionsSerializer) + ]: + self.fields.pop(field, None) else: # Exclude fields which are only used for data export for field in self.get_export_only_fields(**kwargs): diff --git a/src/backend/InvenTree/importer/mixins.py b/src/backend/InvenTree/importer/mixins.py index 969f82afa2..9cd5401606 100644 --- a/src/backend/InvenTree/importer/mixins.py +++ b/src/backend/InvenTree/importer/mixins.py @@ -3,6 +3,8 @@ from rest_framework import fields, serializers from taggit.serializers import TagListSerializerField +import InvenTree.serializers + class DataImportSerializerMixin: """Mixin class for adding data import functionality to a DRF serializer.""" @@ -41,6 +43,14 @@ class DataImportSerializerMixin: for field in self.get_import_exclude_fields(**kwargs): self.fields.pop(field, None) + # Duplication options are never used for data import + for field in [ + name + for name, field in self.fields.items() + if isinstance(field, InvenTree.serializers.DuplicateOptionsSerializer) + ]: + self.fields.pop(field, None) + else: # Exclude fields which are only used for data import for field in self.get_import_only_fields(**kwargs): diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 7ef36f5609..04dc6e79f4 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -32,6 +32,7 @@ from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, s from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( CustomStatusSerializerMixin, + DuplicateOptionsSerializer, FilterableSerializerMixin, InvenTreeCurrencySerializer, InvenTreeDecimalField, @@ -67,40 +68,6 @@ class TotalPriceMixin(serializers.Serializer): ) -class DuplicateOrderSerializer(serializers.Serializer): - """Serializer for specifying options when duplicating an order.""" - - class Meta: - """Metaclass options.""" - - fields = ['order_id', 'copy_lines', 'copy_extra_lines', 'copy_parameters'] - - order_id = serializers.IntegerField( - required=True, label=_('Order ID'), help_text=_('ID of the order to duplicate') - ) - - copy_lines = serializers.BooleanField( - required=False, - default=True, - label=_('Copy Lines'), - help_text=_('Copy line items from the original order'), - ) - - copy_extra_lines = serializers.BooleanField( - required=False, - default=True, - label=_('Copy Extra Lines'), - help_text=_('Copy extra line items from the original order'), - ) - - copy_parameters = serializers.BooleanField( - required=False, - default=True, - label=_('Copy Parameters'), - help_text=_('Copy order parameters from the original order'), - ) - - class AbstractOrderSerializer( CustomStatusSerializerMixin, DataImportExportSerializerMixin, @@ -110,9 +77,9 @@ class AbstractOrderSerializer( ): """Abstract serializer class which provides fields common to all order types.""" - export_exclude_fields = ['notes', 'duplicate'] + export_exclude_fields = ['notes'] - import_exclude_fields = ['notes', 'duplicate'] + import_exclude_fields = ['notes'] # Number of line items in this order line_items = serializers.IntegerField( @@ -195,12 +162,8 @@ class AbstractOrderSerializer( created_by = UserSerializer(read_only=True) - duplicate = DuplicateOrderSerializer( - label=_('Duplicate Order'), - help_text=_('Specify options for duplicating this order'), - required=False, - write_only=True, - ) + # Note: The 'duplicate' field must be defined by each concrete serializer class, + # as it requires a queryset specific to the order model type def validate_reference(self, reference): """Custom validation for the reference field.""" @@ -293,30 +256,21 @@ class AbstractOrderSerializer( instance = super().create(validated_data) if duplicate: - order_id = duplicate.get('order_id', None) - copy_lines = duplicate.get('copy_lines', True) - copy_extra_lines = duplicate.get('copy_extra_lines', True) - copy_parameters = duplicate.get('copy_parameters', True) + original = duplicate['original'] - try: - copy_from = instance.__class__.objects.get(pk=order_id) - except Exception: - # If the order ID is invalid, raise a validation error - raise ValidationError(_('Invalid order ID')) - - if copy_lines: - for line in copy_from.lines.all(): + if duplicate.get('copy_lines', False): + for line in original.lines.all(): instance.clean_line_item(line) line.save() - if copy_extra_lines: - for line in copy_from.extra_lines.all(): + if duplicate.get('copy_extra_lines', False): + for line in original.extra_lines.all(): line.pk = None line.order = instance line.save() - if copy_parameters: - instance.copy_parameters_from(copy_from) + if duplicate.get('copy_parameters', False): + instance.copy_parameters_from(original) return instance @@ -452,6 +406,13 @@ class PurchaseOrderSerializer( return [*fields, 'duplicate'] + duplicate = DuplicateOptionsSerializer( + order.models.PurchaseOrder.objects.all(), + copy_lines=True, + copy_extra_lines=True, + copy_parameters=True, + ) + @staticmethod def annotate_queryset(queryset): """Add extra information to the queryset. @@ -1134,6 +1095,13 @@ class SalesOrderSerializer( return [*fields, 'duplicate'] + duplicate = DuplicateOptionsSerializer( + order.models.SalesOrder.objects.all(), + copy_lines=True, + copy_extra_lines=True, + copy_parameters=True, + ) + @staticmethod def annotate_queryset(queryset): """Add extra information to the queryset. @@ -1411,6 +1379,8 @@ class SalesOrderShipmentSerializer( ): """Serializer for the SalesOrderShipment class.""" + SKIP_CREATE_FIELDS = ['duplicate'] + class Meta: """Metaclass options.""" @@ -1423,6 +1393,7 @@ class SalesOrderShipmentSerializer( 'shipment_address', 'delivery_date', 'checked_by', + 'duplicate', 'reference', 'tracking_number', 'invoice_number', @@ -1507,6 +1478,25 @@ class SalesOrderShipmentSerializer( tags = common.filters.enable_tags_filter() + duplicate = DuplicateOptionsSerializer( + order.models.SalesOrderShipment.objects.all(), copy_parameters=True + ) + + @transaction.atomic + def create(self, validated_data): + """Create a new SalesOrderShipment instance, optionally copying data from an existing shipment.""" + duplicate = validated_data.pop('duplicate', None) + + instance = super().create(validated_data) + + if duplicate: + original = duplicate['original'] + + if duplicate.get('copy_parameters', True): + instance.copy_parameters_from(original) + + return instance + class SalesOrderAllocationSerializer( FilterableSerializerMixin, InvenTreeModelSerializer @@ -2198,6 +2188,14 @@ class ReturnOrderSerializer( return [*fields, 'duplicate'] + # Note: line items cannot be duplicated from a ReturnOrder, + # as they are linked to specific stock items + duplicate = DuplicateOptionsSerializer( + order.models.ReturnOrder.objects.all(), + copy_extra_lines=True, + copy_parameters=True, + ) + @staticmethod def annotate_queryset(queryset): """Custom annotation for the serializer queryset.""" @@ -2485,6 +2483,11 @@ class TransferOrderSerializer( return [*fields, 'duplicate'] + # Note: TransferOrder does not have "extra" line items + duplicate = DuplicateOptionsSerializer( + order.models.TransferOrder.objects.all(), copy_lines=True, copy_parameters=True + ) + @staticmethod def annotate_queryset(queryset): """Add extra information to the queryset. diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 10b944f40f..e72bd91de5 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -573,7 +573,7 @@ class PurchaseOrderTest(OrderTest): # Duplicate with non-existent PK to provoke error data['duplicate'] = { - 'order_id': 10000001, + 'original': 10000001, 'copy_lines': True, 'copy_extra_lines': False, } @@ -584,7 +584,7 @@ class PurchaseOrderTest(OrderTest): response = self.post(reverse('api-po-list'), data, expected_code=400) data['duplicate'] = { - 'order_id': 1, + 'original': 1, 'copy_lines': True, 'copy_extra_lines': False, } @@ -605,7 +605,7 @@ class PurchaseOrderTest(OrderTest): data['reference'] = 'PO-9998' data['duplicate'] = { - 'order_id': 1, + 'original': 1, 'copy_lines': False, 'copy_extra_lines': True, } @@ -1792,7 +1792,7 @@ class SalesOrderTest(OrderTest): { 'reference': 'SO-12345', 'customer': so.customer.pk, - 'duplicate': {'order_id': so.pk, 'copy_parameters': False}, + 'duplicate': {'original': so.pk, 'copy_parameters': False}, }, ) @@ -1809,7 +1809,7 @@ class SalesOrderTest(OrderTest): { 'reference': 'SO-12346', 'customer': so.customer.pk, - 'duplicate': {'order_id': so.pk}, + 'duplicate': {'original': so.pk}, }, ) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index a9a87c7e80..b2b7a361cb 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -407,67 +407,6 @@ class PartBriefSerializer( ) -class DuplicatePartSerializer(serializers.Serializer): - """Serializer for specifying options when duplicating a Part. - - The fields in this serializer control how the Part is duplicated. - """ - - class Meta: - """Metaclass options.""" - - fields = [ - 'part', - 'copy_image', - 'copy_bom', - 'copy_parameters', - 'copy_notes', - 'copy_tests', - ] - - 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, - ) - - copy_notes = serializers.BooleanField( - label=_('Copy Notes'), - help_text=_('Copy notes from original part'), - required=False, - default=True, - ) - - copy_tests = serializers.BooleanField( - label=_('Copy Tests'), - help_text=_('Copy test templates from original part'), - required=False, - default=False, - ) - - class InitialStockSerializer(serializers.Serializer): """Serializer for creating initial stock quantity.""" @@ -601,7 +540,7 @@ class PartSerializer( Used when displaying all details of a single component. """ - import_exclude_fields = ['creation_date', 'creation_user', 'duplicate'] + import_exclude_fields = ['creation_date', 'creation_user'] class Meta: """Metaclass defining serializer fields.""" @@ -1007,11 +946,37 @@ class PartSerializer( ) # Extra fields used only for creation of a new Part instance - duplicate = DuplicatePartSerializer( + duplicate = InvenTree.serializers.DuplicateOptionsSerializer( + Part.objects.all(), label=_('Duplicate Part'), help_text=_('Copy initial data from another Part'), - write_only=True, - required=False, + copy_parameters=True, + copy_fields=[ + { + 'name': 'copy_image', + 'label': _('Copy Image'), + 'help_text': _('Copy image from original part'), + 'default': False, + }, + { + 'name': 'copy_bom', + 'label': _('Copy BOM'), + 'help_text': _('Copy bill of materials from original part'), + 'default': False, + }, + { + 'name': 'copy_notes', + 'label': _('Copy Notes'), + 'help_text': _('Copy notes from original part'), + 'default': True, + }, + { + 'name': 'copy_tests', + 'label': _('Copy Tests'), + 'help_text': _('Copy test templates from original part'), + 'default': False, + }, + ], ) initial_stock = InitialStockSerializer( @@ -1077,7 +1042,7 @@ class PartSerializer( # Copy data from original Part if duplicate: - original = duplicate['part'] + original = duplicate['original'] if duplicate.get('copy_bom', False): instance.copy_bom_from(original) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 011c700884..7752e95dd4 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -1649,7 +1649,7 @@ class PartCreationTests(PartAPITestBase): 'testable': do_copy, 'assembly': do_copy, 'duplicate': { - 'part': 100, + 'original': 100, 'copy_bom': do_copy, 'copy_notes': do_copy, 'copy_image': do_copy, diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 2f694c841c..39fa22579e 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -36,16 +36,18 @@ import { } from '../hooks/UseGenerator'; import { useGlobalSettingsState } from '../states/SettingsStates'; import { RenderPartColumn } from '../tables/ColumnRenderers'; -import { ProjectCodeField, TagsField } from './CommonFields'; +import { DuplicateField, ProjectCodeField, TagsField } from './CommonFields'; /** * Field set for BuildOrder forms */ export function useBuildOrderFields({ create, + duplicateBuildId, modalId }: { create: boolean; + duplicateBuildId?: number | null; modalId: string; }): ApiFormFieldSet { const [destination, setDestination] = useState( @@ -133,7 +135,13 @@ export function useBuildOrderFields({ is_active: true } }, - external: {} + external: {}, + duplicate: DuplicateField({ + originalId: duplicateBuildId, + extraFields: { + copy_parameters: {} + } + }) }; if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) { @@ -144,8 +152,19 @@ export function useBuildOrderFields({ delete fields.external; } + if (!duplicateBuildId) { + delete fields.duplicate; + } + return fields; - }, [create, destination, batchCode, batchGenerator.result, globalSettings]); + }, [ + create, + destination, + batchCode, + batchGenerator.result, + globalSettings, + duplicateBuildId + ]); } export function useBuildOrderOutputFields({ diff --git a/src/frontend/src/forms/CommonFields.tsx b/src/frontend/src/forms/CommonFields.tsx index 07f16e0bb9..988fcb31a4 100644 --- a/src/frontend/src/forms/CommonFields.tsx +++ b/src/frontend/src/forms/CommonFields.tsx @@ -2,6 +2,26 @@ import type { ApiFormFieldType } from '@lib/types/Forms'; import { t } from '@lingui/core/macro'; import { IconList } from '@tabler/icons-react'; +// Generic field for implementing a "duplication options" form field +export function DuplicateField({ + originalId, + extraFields +}: Readonly<{ + originalId?: number | null; + extraFields?: Record; +}>): ApiFormFieldType { + return { + children: { + original: { + value: originalId, + hidden: true + }, + ...extraFields + } + }; +} + +// Generic field for rendering a list of tags within a form export function TagsField({ label, description, diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index 14d3b6218f..bfd2ede4cc 100644 --- a/src/frontend/src/forms/CompanyForms.tsx +++ b/src/frontend/src/forms/CompanyForms.tsx @@ -13,7 +13,7 @@ import { IconPhone } from '@tabler/icons-react'; import { useMemo, useState } from 'react'; -import { TagsField } from './CommonFields'; +import { DuplicateField, TagsField } from './CommonFields'; /** * Field set for SupplierPart instance @@ -21,11 +21,13 @@ import { TagsField } from './CommonFields'; export function useSupplierPartFields({ manufacturerId, manufacturerPartId, - partId + partId, + duplicateSupplierPartId }: { manufacturerId?: number; manufacturerPartId?: number; partId?: number; + duplicateSupplierPartId?: number | null; }) { const [part, setPart] = useState({}); @@ -95,14 +97,34 @@ export function useSupplierPartFields({ icon: }, primary: {}, - active: {} + active: {}, + duplicate: DuplicateField({ + originalId: duplicateSupplierPartId, + extraFields: { + copy_parameters: {} + } + }) }; + if (!duplicateSupplierPartId) { + delete fields.duplicate; + } + return fields; - }, [manufacturerId, manufacturerPartId, partId, part]); + }, [ + manufacturerId, + manufacturerPartId, + partId, + part, + duplicateSupplierPartId + ]); } -export function useManufacturerPartFields() { +export function useManufacturerPartFields({ + duplicateManufacturerPartId +}: { + duplicateManufacturerPartId?: number | null; +} = {}) { return useMemo(() => { const fields: ApiFormFieldSet = { part: {}, @@ -120,18 +142,32 @@ export function useManufacturerPartFields() { MPN: {}, description: {}, tags: TagsField({}), - link: {} + link: {}, + duplicate: DuplicateField({ + originalId: duplicateManufacturerPartId, + extraFields: { + copy_parameters: {} + } + }) }; + if (!duplicateManufacturerPartId) { + delete fields.duplicate; + } + return fields; - }, []); + }, [duplicateManufacturerPartId]); } /** * Field set for editing a company instance */ -export function companyFields(): ApiFormFieldSet { - return { +export function companyFields({ + duplicateCompanyId +}: { + duplicateCompanyId?: number | null; +} = {}): ApiFormFieldSet { + const fields: ApiFormFieldSet = { name: {}, description: {}, website: { @@ -151,6 +187,18 @@ export function companyFields(): ApiFormFieldSet { is_supplier: {}, is_manufacturer: {}, is_customer: {}, - active: {} + active: {}, + duplicate: DuplicateField({ + originalId: duplicateCompanyId, + extraFields: { + copy_parameters: {} + } + }) }; + + if (!duplicateCompanyId) { + delete fields.duplicate; + } + + return fields; } diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 311b64b18f..0ead66d6d9 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -156,7 +156,7 @@ export function usePartFields({ fields.duplicate = { icon: , children: { - part: { + original: { value: duplicatePartInstance?.pk, hidden: true }, diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index aaf4fa83d4..4776e668eb 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -316,7 +316,7 @@ export function usePurchaseOrderFields({ if (!!duplicateOrderId) { fields.duplicate = { children: { - order_id: { + original: { hidden: true, value: duplicateOrderId }, diff --git a/src/frontend/src/forms/ReturnOrderForms.tsx b/src/frontend/src/forms/ReturnOrderForms.tsx index 0fe1866b7b..463e7cc60a 100644 --- a/src/frontend/src/forms/ReturnOrderForms.tsx +++ b/src/frontend/src/forms/ReturnOrderForms.tsx @@ -84,15 +84,10 @@ export function useReturnOrderFields({ if (!!duplicateOrderId) { fields.duplicate = { children: { - order_id: { + original: { hidden: true, value: duplicateOrderId }, - copy_lines: { - // Cannot duplicate lines from a return order! - value: false, - hidden: true - }, copy_extra_lines: {}, copy_parameters: {} } diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index 163861db24..90ec80c034 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -97,7 +97,7 @@ export function useSalesOrderFields({ if (!!duplicateOrderId) { fields.duplicate = { children: { - order_id: { + original: { hidden: true, value: duplicateOrderId }, diff --git a/src/frontend/src/forms/TransferOrderForms.tsx b/src/frontend/src/forms/TransferOrderForms.tsx index a80915e393..a3945fbec5 100644 --- a/src/frontend/src/forms/TransferOrderForms.tsx +++ b/src/frontend/src/forms/TransferOrderForms.tsx @@ -54,13 +54,12 @@ export function useTransferOrderFields({ if (!!duplicateOrderId) { fields.duplicate = { children: { - order_id: { + original: { hidden: true, value: duplicateOrderId }, copy_lines: {}, - // Transfer Orders don't have extra lines for now... - copy_extra_lines: { hidden: true, value: false } + copy_parameters: {} } }; } diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 6b8d8e3459..7fbd0d3307 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -632,6 +632,7 @@ export default function BuildDetail() { const duplicateBuildOrderFields = useBuildOrderFields({ create: false, + duplicateBuildId: build.pk, modalId: 'duplicate-build-order' }); diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index 2b779d4112..5765cc377f 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -31,6 +31,7 @@ import { DetailsImage } from '../../components/details/DetailsImage'; import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { DeleteItemAction, + DuplicateItemAction, EditItemAction, OptionsActionDropdown } from '../../components/items/ActionDropdown'; @@ -43,6 +44,7 @@ import { PanelGroup } from '../../components/panels/PanelGroup'; import ParametersPanel from '../../components/panels/ParametersPanel'; import { companyFields } from '../../forms/CompanyForms'; import { + useCreateApiFormModal, useDeleteApiFormModal, useEditApiFormModal } from '../../hooks/UseForm'; @@ -293,11 +295,23 @@ export default function CompanyDetail(props: Readonly) { url: ApiEndpoints.company_list, pk: company?.pk, title: t`Edit Company`, - fields: companyFields(), + fields: useMemo(() => companyFields({}), []), queryParams: new URLSearchParams({ tags: 'true' }), onFormSuccess: refreshInstance }); + const duplicateCompany = useCreateApiFormModal({ + url: ApiEndpoints.company_list, + title: t`Duplicate Company`, + initialData: useMemo(() => ({ ...company }), [company]), + fields: useMemo( + () => companyFields({ duplicateCompanyId: company?.pk }), + [company] + ), + follow: true, + modelType: ModelType.company + }); + const deleteCompany = useDeleteApiFormModal({ url: ApiEndpoints.company_list, pk: company?.pk, @@ -322,6 +336,10 @@ export default function CompanyDetail(props: Readonly) { hidden: !user.hasChangeRole(UserRoles.purchase_order), onClick: () => editCompany.open() }), + DuplicateItemAction({ + hidden: !user.hasAddRole(UserRoles.purchase_order), + onClick: () => duplicateCompany.open() + }), DeleteItemAction({ hidden: !user.hasDeleteRole(UserRoles.purchase_order), onClick: () => deleteCompany.open() @@ -345,6 +363,7 @@ export default function CompanyDetail(props: Readonly) { <> {editCompany.modal} {deleteCompany.modal} + {duplicateCompany.modal} { return { ...createPartFields, - duplicate: { - children: { - part: { - value: selectedPart.pk, - hidden: true - }, + duplicate: DuplicateField({ + originalId: selectedPart.pk, + extraFields: { copy_image: { value: true }, @@ -337,7 +335,7 @@ export function PartListTable({ hidden: !selectedPart.testable } } - } + }) }; }, [createPartFields, globalSettings, selectedPart]); diff --git a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx index 0722b58894..20d4297661 100644 --- a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx +++ b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx @@ -133,10 +133,14 @@ export function ManufacturerPartTable({ table: table }); + const duplicateManufacturerPartFields = useManufacturerPartFields({ + duplicateManufacturerPartId: selectedPart?.pk + }); + const duplicateManufacturerPart = useCreateApiFormModal({ url: ApiEndpoints.manufacturer_part_list, title: t`Add Manufacturer Part`, - fields: useMemo(() => manufacturerPartFields, [manufacturerPartFields]), + fields: duplicateManufacturerPartFields, table: table, initialData: { ...selectedPart diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index 4e38368bce..b6e087fbf4 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -292,10 +292,14 @@ export function SupplierPartTable({ } }); + const duplicateSupplierPartFields = useSupplierPartFields({ + duplicateSupplierPartId: selectedSupplierPart?.pk + }); + const duplicateSupplierPart = useCreateApiFormModal({ url: ApiEndpoints.supplier_part_list, title: t`Add Supplier Part`, - fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]), + fields: duplicateSupplierPartFields, initialData: { ...selectedSupplierPart, primary: false,