2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 06:00:38 +00:00

[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
This commit is contained in:
Oliver
2026-07-03 18:04:59 +10:00
committed by GitHub
parent 5595a0a52b
commit 15c64d6695
25 changed files with 458 additions and 169 deletions
@@ -1,11 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v513 -> 2026-06-25 : https://github.com/inventree/InvenTree/pull/12250
- Adds "active" field to the ProjectCode model and API endpoints - Adds "active" field to the ProjectCode model and API endpoints
+102 -1
View File
@@ -600,7 +600,7 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
Default implementation returns an empty list Default implementation returns an empty list
""" """
return [] return getattr(self, 'SKIP_CREATE_FIELDS', [])
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."""
@@ -921,3 +921,104 @@ class ContentTypeField(serializers.ChoiceField):
) )
return content_type 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', ''),
)
@@ -34,6 +34,7 @@ from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import ( from InvenTree.serializers import (
CustomStatusSerializerMixin, CustomStatusSerializerMixin,
DuplicateOptionsSerializer,
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeDecimalField, InvenTreeDecimalField,
InvenTreeModelSerializer, InvenTreeModelSerializer,
@@ -67,6 +68,8 @@ class BuildSerializer(
): ):
"""Serializes a Build object.""" """Serializes a Build object."""
SKIP_CREATE_FIELDS = ['duplicate']
class Meta: class Meta:
"""Serializer metaclass.""" """Serializer metaclass."""
@@ -80,6 +83,7 @@ class BuildSerializer(
'completed', 'completed',
'completion_date', 'completion_date',
'destination', 'destination',
'duplicate',
'external', 'external',
'parent', 'parent',
'part', 'part',
@@ -190,12 +194,29 @@ class BuildSerializer(
return queryset return queryset
duplicate = DuplicateOptionsSerializer(Build.objects.all(), copy_parameters=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Determine if extra serializer fields are required.""" """Determine if extra serializer fields are required."""
kwargs.pop('create', False) kwargs.pop('create', False)
super().__init__(*args, **kwargs) 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): def validate_reference(self, reference):
"""Custom validation for the Build reference field.""" """Custom validation for the Build reference field."""
# Ensure the reference matches the required pattern # Ensure the reference matches the required pattern
@@ -1,5 +1,6 @@
"""JSON serializers for Company app.""" """JSON serializers for Company app."""
from django.db import transaction
from django.db.models import Prefetch from django.db.models import Prefetch
from django.utils.translation import gettext_lazy as _ 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.mixins import DataImportExportSerializerMixin
from InvenTree.ready import isGeneratingSchema from InvenTree.ready import isGeneratingSchema
from InvenTree.serializers import ( from InvenTree.serializers import (
DuplicateOptionsSerializer,
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, InvenTreeDecimalField,
@@ -118,6 +120,8 @@ class CompanySerializer(
import_exclude_fields = ['image'] import_exclude_fields = ['image']
SKIP_CREATE_FIELDS = ['duplicate']
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@@ -132,6 +136,7 @@ class CompanySerializer(
'email', 'email',
'currency', 'currency',
'contact', 'contact',
'duplicate',
'link', 'link',
'image', 'image',
'active', 'active',
@@ -191,6 +196,23 @@ class CompanySerializer(
parameters = common.filters.enable_parameters_filter() 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() @register_importer()
class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
@@ -217,6 +239,8 @@ class ManufacturerPartSerializer(
): ):
"""Serializer for ManufacturerPart object.""" """Serializer for ManufacturerPart object."""
SKIP_CREATE_FIELDS = ['duplicate']
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@@ -229,6 +253,7 @@ class ManufacturerPartSerializer(
'manufacturer', 'manufacturer',
'manufacturer_detail', 'manufacturer_detail',
'description', 'description',
'duplicate',
'MPN', 'MPN',
'link', 'link',
'barcode_hash', 'barcode_hash',
@@ -241,6 +266,25 @@ class ManufacturerPartSerializer(
parameters = common.filters.enable_parameters_filter() 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( part_detail = OptionalField(
serializer_class=part_serializers.PartBriefSerializer, serializer_class=part_serializers.PartBriefSerializer,
serializer_kwargs={ serializer_kwargs={
@@ -323,6 +367,8 @@ class SupplierPartSerializer(
export_exclude_fields = ['tags'] export_exclude_fields = ['tags']
SKIP_CREATE_FIELDS = ['duplicate']
export_child_fields = [ export_child_fields = [
'part_detail.name', 'part_detail.name',
'part_detail.description', 'part_detail.description',
@@ -339,6 +385,7 @@ class SupplierPartSerializer(
'available', 'available',
'availability_updated', 'availability_updated',
'description', 'description',
'duplicate',
'in_stock', 'in_stock',
'on_order', 'on_order',
'link', 'link',
@@ -494,6 +541,10 @@ class SupplierPartSerializer(
# Date fields # Date fields
updated = serializers.DateTimeField(allow_null=True, read_only=True) updated = serializers.DateTimeField(allow_null=True, read_only=True)
duplicate = DuplicateOptionsSerializer(
SupplierPart.objects.all(), copy_parameters=True
)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Annotate the SupplierPart queryset with extra fields. """Annotate the SupplierPart queryset with extra fields.
@@ -522,8 +573,11 @@ class SupplierPartSerializer(
return response return response
@transaction.atomic
def create(self, validated_data): def create(self, validated_data):
"""Extract manufacturer data and process ManufacturerPart.""" """Extract manufacturer data and process ManufacturerPart."""
duplicate = validated_data.pop('duplicate', None)
# Extract 'available' quantity from the serializer # Extract 'available' quantity from the serializer
available = validated_data.pop('available', None) available = validated_data.pop('available', None)
@@ -541,6 +595,12 @@ class SupplierPartSerializer(
kwargs = {'manufacturer': manufacturer, 'MPN': MPN} kwargs = {'manufacturer': manufacturer, 'MPN': MPN}
supplier_part.save(**kwargs) supplier_part.save(**kwargs)
if duplicate:
original = duplicate['original']
if duplicate.get('copy_parameters', True):
supplier_part.copy_parameters_from(original)
return supplier_part return supplier_part
@@ -16,6 +16,7 @@ from taggit.serializers import TagListSerializerField
import data_exporter.serializers import data_exporter.serializers
import data_exporter.tasks import data_exporter.tasks
import InvenTree.exceptions import InvenTree.exceptions
import InvenTree.serializers
from common.models import DataOutput from common.models import DataOutput
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
@@ -64,6 +65,14 @@ class DataExportSerializerMixin:
# Exclude fields which are not required for data export # Exclude fields which are not required for data export
for field in self.get_export_exclude_fields(**kwargs): for field in self.get_export_exclude_fields(**kwargs):
self.fields.pop(field, None) 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: else:
# Exclude fields which are only used for data export # Exclude fields which are only used for data export
for field in self.get_export_only_fields(**kwargs): for field in self.get_export_only_fields(**kwargs):
+10
View File
@@ -3,6 +3,8 @@
from rest_framework import fields, serializers from rest_framework import fields, serializers
from taggit.serializers import TagListSerializerField from taggit.serializers import TagListSerializerField
import InvenTree.serializers
class DataImportSerializerMixin: class DataImportSerializerMixin:
"""Mixin class for adding data import functionality to a DRF serializer.""" """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): for field in self.get_import_exclude_fields(**kwargs):
self.fields.pop(field, None) 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: else:
# Exclude fields which are only used for data import # Exclude fields which are only used for data import
for field in self.get_import_only_fields(**kwargs): for field in self.get_import_only_fields(**kwargs):
+61 -58
View File
@@ -32,6 +32,7 @@ from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, s
from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import ( from InvenTree.serializers import (
CustomStatusSerializerMixin, CustomStatusSerializerMixin,
DuplicateOptionsSerializer,
FilterableSerializerMixin, FilterableSerializerMixin,
InvenTreeCurrencySerializer, InvenTreeCurrencySerializer,
InvenTreeDecimalField, 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( class AbstractOrderSerializer(
CustomStatusSerializerMixin, CustomStatusSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
@@ -110,9 +77,9 @@ class AbstractOrderSerializer(
): ):
"""Abstract serializer class which provides fields common to all order types.""" """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 # Number of line items in this order
line_items = serializers.IntegerField( line_items = serializers.IntegerField(
@@ -195,12 +162,8 @@ class AbstractOrderSerializer(
created_by = UserSerializer(read_only=True) created_by = UserSerializer(read_only=True)
duplicate = DuplicateOrderSerializer( # Note: The 'duplicate' field must be defined by each concrete serializer class,
label=_('Duplicate Order'), # as it requires a queryset specific to the order model type
help_text=_('Specify options for duplicating this order'),
required=False,
write_only=True,
)
def validate_reference(self, reference): def validate_reference(self, reference):
"""Custom validation for the reference field.""" """Custom validation for the reference field."""
@@ -293,30 +256,21 @@ class AbstractOrderSerializer(
instance = super().create(validated_data) instance = super().create(validated_data)
if duplicate: if duplicate:
order_id = duplicate.get('order_id', None) original = duplicate['original']
copy_lines = duplicate.get('copy_lines', True)
copy_extra_lines = duplicate.get('copy_extra_lines', True)
copy_parameters = duplicate.get('copy_parameters', True)
try: if duplicate.get('copy_lines', False):
copy_from = instance.__class__.objects.get(pk=order_id) for line in original.lines.all():
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():
instance.clean_line_item(line) instance.clean_line_item(line)
line.save() line.save()
if copy_extra_lines: if duplicate.get('copy_extra_lines', False):
for line in copy_from.extra_lines.all(): for line in original.extra_lines.all():
line.pk = None line.pk = None
line.order = instance line.order = instance
line.save() line.save()
if copy_parameters: if duplicate.get('copy_parameters', False):
instance.copy_parameters_from(copy_from) instance.copy_parameters_from(original)
return instance return instance
@@ -452,6 +406,13 @@ class PurchaseOrderSerializer(
return [*fields, 'duplicate'] return [*fields, 'duplicate']
duplicate = DuplicateOptionsSerializer(
order.models.PurchaseOrder.objects.all(),
copy_lines=True,
copy_extra_lines=True,
copy_parameters=True,
)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add extra information to the queryset. """Add extra information to the queryset.
@@ -1134,6 +1095,13 @@ class SalesOrderSerializer(
return [*fields, 'duplicate'] return [*fields, 'duplicate']
duplicate = DuplicateOptionsSerializer(
order.models.SalesOrder.objects.all(),
copy_lines=True,
copy_extra_lines=True,
copy_parameters=True,
)
@staticmethod @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add extra information to the queryset. """Add extra information to the queryset.
@@ -1411,6 +1379,8 @@ class SalesOrderShipmentSerializer(
): ):
"""Serializer for the SalesOrderShipment class.""" """Serializer for the SalesOrderShipment class."""
SKIP_CREATE_FIELDS = ['duplicate']
class Meta: class Meta:
"""Metaclass options.""" """Metaclass options."""
@@ -1423,6 +1393,7 @@ class SalesOrderShipmentSerializer(
'shipment_address', 'shipment_address',
'delivery_date', 'delivery_date',
'checked_by', 'checked_by',
'duplicate',
'reference', 'reference',
'tracking_number', 'tracking_number',
'invoice_number', 'invoice_number',
@@ -1507,6 +1478,25 @@ class SalesOrderShipmentSerializer(
tags = common.filters.enable_tags_filter() 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( class SalesOrderAllocationSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer FilterableSerializerMixin, InvenTreeModelSerializer
@@ -2198,6 +2188,14 @@ class ReturnOrderSerializer(
return [*fields, 'duplicate'] 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 @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Custom annotation for the serializer queryset.""" """Custom annotation for the serializer queryset."""
@@ -2485,6 +2483,11 @@ class TransferOrderSerializer(
return [*fields, 'duplicate'] 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 @staticmethod
def annotate_queryset(queryset): def annotate_queryset(queryset):
"""Add extra information to the queryset. """Add extra information to the queryset.
+5 -5
View File
@@ -573,7 +573,7 @@ class PurchaseOrderTest(OrderTest):
# Duplicate with non-existent PK to provoke error # Duplicate with non-existent PK to provoke error
data['duplicate'] = { data['duplicate'] = {
'order_id': 10000001, 'original': 10000001,
'copy_lines': True, 'copy_lines': True,
'copy_extra_lines': False, 'copy_extra_lines': False,
} }
@@ -584,7 +584,7 @@ class PurchaseOrderTest(OrderTest):
response = self.post(reverse('api-po-list'), data, expected_code=400) response = self.post(reverse('api-po-list'), data, expected_code=400)
data['duplicate'] = { data['duplicate'] = {
'order_id': 1, 'original': 1,
'copy_lines': True, 'copy_lines': True,
'copy_extra_lines': False, 'copy_extra_lines': False,
} }
@@ -605,7 +605,7 @@ class PurchaseOrderTest(OrderTest):
data['reference'] = 'PO-9998' data['reference'] = 'PO-9998'
data['duplicate'] = { data['duplicate'] = {
'order_id': 1, 'original': 1,
'copy_lines': False, 'copy_lines': False,
'copy_extra_lines': True, 'copy_extra_lines': True,
} }
@@ -1792,7 +1792,7 @@ class SalesOrderTest(OrderTest):
{ {
'reference': 'SO-12345', 'reference': 'SO-12345',
'customer': so.customer.pk, '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', 'reference': 'SO-12346',
'customer': so.customer.pk, 'customer': so.customer.pk,
'duplicate': {'order_id': so.pk}, 'duplicate': {'original': so.pk},
}, },
) )
+31 -66
View File
@@ -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): class InitialStockSerializer(serializers.Serializer):
"""Serializer for creating initial stock quantity.""" """Serializer for creating initial stock quantity."""
@@ -601,7 +540,7 @@ class PartSerializer(
Used when displaying all details of a single component. 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: class Meta:
"""Metaclass defining serializer fields.""" """Metaclass defining serializer fields."""
@@ -1007,11 +946,37 @@ class PartSerializer(
) )
# Extra fields used only for creation of a new Part instance # Extra fields used only for creation of a new Part instance
duplicate = DuplicatePartSerializer( duplicate = InvenTree.serializers.DuplicateOptionsSerializer(
Part.objects.all(),
label=_('Duplicate Part'), label=_('Duplicate Part'),
help_text=_('Copy initial data from another Part'), help_text=_('Copy initial data from another Part'),
write_only=True, copy_parameters=True,
required=False, 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( initial_stock = InitialStockSerializer(
@@ -1077,7 +1042,7 @@ class PartSerializer(
# Copy data from original Part # Copy data from original Part
if duplicate: if duplicate:
original = duplicate['part'] original = duplicate['original']
if duplicate.get('copy_bom', False): if duplicate.get('copy_bom', False):
instance.copy_bom_from(original) instance.copy_bom_from(original)
+1 -1
View File
@@ -1649,7 +1649,7 @@ class PartCreationTests(PartAPITestBase):
'testable': do_copy, 'testable': do_copy,
'assembly': do_copy, 'assembly': do_copy,
'duplicate': { 'duplicate': {
'part': 100, 'original': 100,
'copy_bom': do_copy, 'copy_bom': do_copy,
'copy_notes': do_copy, 'copy_notes': do_copy,
'copy_image': do_copy, 'copy_image': do_copy,
+22 -3
View File
@@ -36,16 +36,18 @@ import {
} from '../hooks/UseGenerator'; } from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsStates'; import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers'; import { RenderPartColumn } from '../tables/ColumnRenderers';
import { ProjectCodeField, TagsField } from './CommonFields'; import { DuplicateField, ProjectCodeField, TagsField } from './CommonFields';
/** /**
* Field set for BuildOrder forms * Field set for BuildOrder forms
*/ */
export function useBuildOrderFields({ export function useBuildOrderFields({
create, create,
duplicateBuildId,
modalId modalId
}: { }: {
create: boolean; create: boolean;
duplicateBuildId?: number | null;
modalId: string; modalId: string;
}): ApiFormFieldSet { }): ApiFormFieldSet {
const [destination, setDestination] = useState<number | null | undefined>( const [destination, setDestination] = useState<number | null | undefined>(
@@ -133,7 +135,13 @@ export function useBuildOrderFields({
is_active: true is_active: true
} }
}, },
external: {} external: {},
duplicate: DuplicateField({
originalId: duplicateBuildId,
extraFields: {
copy_parameters: {}
}
})
}; };
if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) { if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) {
@@ -144,8 +152,19 @@ export function useBuildOrderFields({
delete fields.external; delete fields.external;
} }
if (!duplicateBuildId) {
delete fields.duplicate;
}
return fields; return fields;
}, [create, destination, batchCode, batchGenerator.result, globalSettings]); }, [
create,
destination,
batchCode,
batchGenerator.result,
globalSettings,
duplicateBuildId
]);
} }
export function useBuildOrderOutputFields({ export function useBuildOrderOutputFields({
+20
View File
@@ -2,6 +2,26 @@ import type { ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { IconList } from '@tabler/icons-react'; 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<string, any>;
}>): ApiFormFieldType {
return {
children: {
original: {
value: originalId,
hidden: true
},
...extraFields
}
};
}
// Generic field for rendering a list of tags within a form
export function TagsField({ export function TagsField({
label, label,
description, description,
+58 -10
View File
@@ -13,7 +13,7 @@ import {
IconPhone IconPhone
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { TagsField } from './CommonFields'; import { DuplicateField, TagsField } from './CommonFields';
/** /**
* Field set for SupplierPart instance * Field set for SupplierPart instance
@@ -21,11 +21,13 @@ import { TagsField } from './CommonFields';
export function useSupplierPartFields({ export function useSupplierPartFields({
manufacturerId, manufacturerId,
manufacturerPartId, manufacturerPartId,
partId partId,
duplicateSupplierPartId
}: { }: {
manufacturerId?: number; manufacturerId?: number;
manufacturerPartId?: number; manufacturerPartId?: number;
partId?: number; partId?: number;
duplicateSupplierPartId?: number | null;
}) { }) {
const [part, setPart] = useState<any>({}); const [part, setPart] = useState<any>({});
@@ -95,14 +97,34 @@ export function useSupplierPartFields({
icon: <IconPackage /> icon: <IconPackage />
}, },
primary: {}, primary: {},
active: {} active: {},
duplicate: DuplicateField({
originalId: duplicateSupplierPartId,
extraFields: {
copy_parameters: {}
}
})
}; };
if (!duplicateSupplierPartId) {
delete fields.duplicate;
}
return fields; return fields;
}, [manufacturerId, manufacturerPartId, partId, part]); }, [
manufacturerId,
manufacturerPartId,
partId,
part,
duplicateSupplierPartId
]);
} }
export function useManufacturerPartFields() { export function useManufacturerPartFields({
duplicateManufacturerPartId
}: {
duplicateManufacturerPartId?: number | null;
} = {}) {
return useMemo(() => { return useMemo(() => {
const fields: ApiFormFieldSet = { const fields: ApiFormFieldSet = {
part: {}, part: {},
@@ -120,18 +142,32 @@ export function useManufacturerPartFields() {
MPN: {}, MPN: {},
description: {}, description: {},
tags: TagsField({}), tags: TagsField({}),
link: {} link: {},
duplicate: DuplicateField({
originalId: duplicateManufacturerPartId,
extraFields: {
copy_parameters: {}
}
})
}; };
if (!duplicateManufacturerPartId) {
delete fields.duplicate;
}
return fields; return fields;
}, []); }, [duplicateManufacturerPartId]);
} }
/** /**
* Field set for editing a company instance * Field set for editing a company instance
*/ */
export function companyFields(): ApiFormFieldSet { export function companyFields({
return { duplicateCompanyId
}: {
duplicateCompanyId?: number | null;
} = {}): ApiFormFieldSet {
const fields: ApiFormFieldSet = {
name: {}, name: {},
description: {}, description: {},
website: { website: {
@@ -151,6 +187,18 @@ export function companyFields(): ApiFormFieldSet {
is_supplier: {}, is_supplier: {},
is_manufacturer: {}, is_manufacturer: {},
is_customer: {}, is_customer: {},
active: {} active: {},
duplicate: DuplicateField({
originalId: duplicateCompanyId,
extraFields: {
copy_parameters: {}
}
})
}; };
if (!duplicateCompanyId) {
delete fields.duplicate;
}
return fields;
} }
+1 -1
View File
@@ -156,7 +156,7 @@ export function usePartFields({
fields.duplicate = { fields.duplicate = {
icon: <IconCopy />, icon: <IconCopy />,
children: { children: {
part: { original: {
value: duplicatePartInstance?.pk, value: duplicatePartInstance?.pk,
hidden: true hidden: true
}, },
@@ -316,7 +316,7 @@ export function usePurchaseOrderFields({
if (!!duplicateOrderId) { if (!!duplicateOrderId) {
fields.duplicate = { fields.duplicate = {
children: { children: {
order_id: { original: {
hidden: true, hidden: true,
value: duplicateOrderId value: duplicateOrderId
}, },
+1 -6
View File
@@ -84,15 +84,10 @@ export function useReturnOrderFields({
if (!!duplicateOrderId) { if (!!duplicateOrderId) {
fields.duplicate = { fields.duplicate = {
children: { children: {
order_id: { original: {
hidden: true, hidden: true,
value: duplicateOrderId value: duplicateOrderId
}, },
copy_lines: {
// Cannot duplicate lines from a return order!
value: false,
hidden: true
},
copy_extra_lines: {}, copy_extra_lines: {},
copy_parameters: {} copy_parameters: {}
} }
+1 -1
View File
@@ -97,7 +97,7 @@ export function useSalesOrderFields({
if (!!duplicateOrderId) { if (!!duplicateOrderId) {
fields.duplicate = { fields.duplicate = {
children: { children: {
order_id: { original: {
hidden: true, hidden: true,
value: duplicateOrderId value: duplicateOrderId
}, },
@@ -54,13 +54,12 @@ export function useTransferOrderFields({
if (!!duplicateOrderId) { if (!!duplicateOrderId) {
fields.duplicate = { fields.duplicate = {
children: { children: {
order_id: { original: {
hidden: true, hidden: true,
value: duplicateOrderId value: duplicateOrderId
}, },
copy_lines: {}, copy_lines: {},
// Transfer Orders don't have extra lines for now... copy_parameters: {}
copy_extra_lines: { hidden: true, value: false }
} }
}; };
} }
@@ -632,6 +632,7 @@ export default function BuildDetail() {
const duplicateBuildOrderFields = useBuildOrderFields({ const duplicateBuildOrderFields = useBuildOrderFields({
create: false, create: false,
duplicateBuildId: build.pk,
modalId: 'duplicate-build-order' modalId: 'duplicate-build-order'
}); });
@@ -31,6 +31,7 @@ import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails'; import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { import {
DeleteItemAction, DeleteItemAction,
DuplicateItemAction,
EditItemAction, EditItemAction,
OptionsActionDropdown OptionsActionDropdown
} from '../../components/items/ActionDropdown'; } from '../../components/items/ActionDropdown';
@@ -43,6 +44,7 @@ import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel'; import ParametersPanel from '../../components/panels/ParametersPanel';
import { companyFields } from '../../forms/CompanyForms'; import { companyFields } from '../../forms/CompanyForms';
import { import {
useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
useEditApiFormModal useEditApiFormModal
} from '../../hooks/UseForm'; } from '../../hooks/UseForm';
@@ -293,11 +295,23 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
url: ApiEndpoints.company_list, url: ApiEndpoints.company_list,
pk: company?.pk, pk: company?.pk,
title: t`Edit Company`, title: t`Edit Company`,
fields: companyFields(), fields: useMemo(() => companyFields({}), []),
queryParams: new URLSearchParams({ tags: 'true' }), queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance 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({ const deleteCompany = useDeleteApiFormModal({
url: ApiEndpoints.company_list, url: ApiEndpoints.company_list,
pk: company?.pk, pk: company?.pk,
@@ -322,6 +336,10 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
hidden: !user.hasChangeRole(UserRoles.purchase_order), hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => editCompany.open() onClick: () => editCompany.open()
}), }),
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.purchase_order),
onClick: () => duplicateCompany.open()
}),
DeleteItemAction({ DeleteItemAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order), hidden: !user.hasDeleteRole(UserRoles.purchase_order),
onClick: () => deleteCompany.open() onClick: () => deleteCompany.open()
@@ -345,6 +363,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
<> <>
{editCompany.modal} {editCompany.modal}
{deleteCompany.modal} {deleteCompany.modal}
{duplicateCompany.modal}
<InstanceDetail <InstanceDetail
query={instanceQuery} query={instanceQuery}
requiredPermission={ModelType.company} requiredPermission={ModelType.company}
@@ -220,10 +220,14 @@ export default function ManufacturerPartDetail() {
onFormSuccess: refreshInstance onFormSuccess: refreshInstance
}); });
const duplicateManufacturerPartFields = useManufacturerPartFields({
duplicateManufacturerPartId: manufacturerPart?.pk
});
const duplicateManufacturerPart = useCreateApiFormModal({ const duplicateManufacturerPart = useCreateApiFormModal({
url: ApiEndpoints.manufacturer_part_list, url: ApiEndpoints.manufacturer_part_list,
title: t`Add Manufacturer Part`, title: t`Add Manufacturer Part`,
fields: editManufacturerPartFields, fields: duplicateManufacturerPartFields,
initialData: { initialData: {
...manufacturerPart ...manufacturerPart
}, },
@@ -357,10 +357,14 @@ export default function SupplierPartDetail() {
} }
}); });
const duplicateSupplierPartFields = useSupplierPartFields({
duplicateSupplierPartId: supplierPart?.pk
});
const duplicateSupplierPart = useCreateApiFormModal({ const duplicateSupplierPart = useCreateApiFormModal({
url: ApiEndpoints.supplier_part_list, url: ApiEndpoints.supplier_part_list,
title: t`Add Supplier Part`, title: t`Add Supplier Part`,
fields: supplierPartFields, fields: duplicateSupplierPartFields,
initialData: { initialData: {
...supplierPart ...supplierPart
}, },
+5 -7
View File
@@ -24,6 +24,7 @@ import { ActionDropdown } from '../../components/items/ActionDropdown';
import ImportPartWizard from '../../components/wizards/ImportPartWizard'; import ImportPartWizard from '../../components/wizards/ImportPartWizard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard'; import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters'; import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { DuplicateField } from '../../forms/CommonFields';
import { dataImporterSessionFields } from '../../forms/ImporterForms'; import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { usePartFields } from '../../forms/PartForms'; import { usePartFields } from '../../forms/PartForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
@@ -312,12 +313,9 @@ export function PartListTable({
const duplicatePartFields: ApiFormFieldSet = useMemo(() => { const duplicatePartFields: ApiFormFieldSet = useMemo(() => {
return { return {
...createPartFields, ...createPartFields,
duplicate: { duplicate: DuplicateField({
children: { originalId: selectedPart.pk,
part: { extraFields: {
value: selectedPart.pk,
hidden: true
},
copy_image: { copy_image: {
value: true value: true
}, },
@@ -337,7 +335,7 @@ export function PartListTable({
hidden: !selectedPart.testable hidden: !selectedPart.testable
} }
} }
} })
}; };
}, [createPartFields, globalSettings, selectedPart]); }, [createPartFields, globalSettings, selectedPart]);
@@ -133,10 +133,14 @@ export function ManufacturerPartTable({
table: table table: table
}); });
const duplicateManufacturerPartFields = useManufacturerPartFields({
duplicateManufacturerPartId: selectedPart?.pk
});
const duplicateManufacturerPart = useCreateApiFormModal({ const duplicateManufacturerPart = useCreateApiFormModal({
url: ApiEndpoints.manufacturer_part_list, url: ApiEndpoints.manufacturer_part_list,
title: t`Add Manufacturer Part`, title: t`Add Manufacturer Part`,
fields: useMemo(() => manufacturerPartFields, [manufacturerPartFields]), fields: duplicateManufacturerPartFields,
table: table, table: table,
initialData: { initialData: {
...selectedPart ...selectedPart
@@ -292,10 +292,14 @@ export function SupplierPartTable({
} }
}); });
const duplicateSupplierPartFields = useSupplierPartFields({
duplicateSupplierPartId: selectedSupplierPart?.pk
});
const duplicateSupplierPart = useCreateApiFormModal({ const duplicateSupplierPart = useCreateApiFormModal({
url: ApiEndpoints.supplier_part_list, url: ApiEndpoints.supplier_part_list,
title: t`Add Supplier Part`, title: t`Add Supplier Part`,
fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]), fields: duplicateSupplierPartFields,
initialData: { initialData: {
...selectedSupplierPart, ...selectedSupplierPart,
primary: false, primary: false,