diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index 62a3a6c43e..d5c5b2ddac 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -8,7 +8,7 @@ name: Frontend on: push: - branches-ignore: ["l10*", "dependabot/*", "backport/*"] + branches-ignore: ["l10*", "dependabot/**", "backport/**"] pull_request: branches-ignore: ["l10*"] diff --git a/.github/workflows/import_export.yaml b/.github/workflows/import_export.yaml index c251bab557..36f7f6d71d 100644 --- a/.github/workflows/import_export.yaml +++ b/.github/workflows/import_export.yaml @@ -7,7 +7,7 @@ name: Import / Export on: push: - branches-ignore: ["l10*", "dependabot/*", "backport/*"] + branches-ignore: ["l10*", "dependabot/**", "backport/**"] pull_request: branches-ignore: ["l10*"] diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 9dab7c79a1..e3fe43fbde 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -4,7 +4,7 @@ name: QC on: push: - branches-ignore: ["l10*", "dependabot/*", "backport/*"] + branches-ignore: ["l10*", "dependabot/**", "backport/**"] pull_request: branches-ignore: ["l10*"] diff --git a/docs/docs/report/index.md b/docs/docs/report/index.md index 59ebc10c72..39fafdacc0 100644 --- a/docs/docs/report/index.md +++ b/docs/docs/report/index.md @@ -53,20 +53,20 @@ To read more about the capabilities of the report templating engine, and how to ## Creating Templates -Report and label templates can be created (and edited) via the [admin interface](../settings/admin.md), under the *Report* section. +Report and label templates are managed from the [Admin Center](../settings/admin.md#admin-center), which provides dedicated panels (under the *Reporting* group) for each template type: -Select the type of template you are wanting to create (a *Report Template* or *Label Template*) and press the *Add* button in the top right corner: +- **Label Templates** - Create and edit [label templates](./labels.md) +- **Report Templates** - Create and edit [report templates](./report.md) +- **Report Snippets** - Manage reusable [snippet](#report-snippets) files +- **Report Assets** - Manage uploaded [asset](#report-assets) files -{{ image("report/report_template_admin.png", "Report template admin") }} +Label and report templates are created and edited using the built-in [template editor](./template_editor.md), which allows templates to be written directly within the browser, with a live preview of the rendered output. !!! tip "Staff Access Only" - Only users with staff access can upload or edit report template files. + Only users with staff access can create, upload or edit templates, snippets and assets. -!!! info "Editing Reports" - Existing reports can be edited from the admin interface, in the same location as described above. To change the contents of the template, re-upload a template file, to override the existing template data. - -!!! tip "Template Editor" - InvenTree also provides a powerful [template editor](./template_editor.md) which allows for the creation and editing of report templates directly within the browser. +!!! info "Backend Admin Interface" + Templates can also be managed at a lower level via the [backend admin interface](../settings/admin.md#backend-admin-interface), under the *Report* section. This is recommended for advanced users only. ### Name and Description @@ -183,7 +183,7 @@ When WeasyPrint renders a template to PDF it can make outbound requests to load |---|---| | `data:` URIs | Always permitted — self-contained, no network access | | `file://` | Always blocked — assets and images must be inlined as `data:` URIs before reaching WeasyPrint | -| `http` / `https` | Disabled by default, but can be blocked - see *Remote URL Fetching* below | +| `http` / `https` | Disabled by default, but can be enabled - see *Remote URL Fetching* below | | Any other scheme | Always blocked | HTTP redirects are also disabled: a URL that passes validation cannot redirect to an internal address. diff --git a/docs/docs/report/template_editor.md b/docs/docs/report/template_editor.md index 81a01f0ef4..3008434944 100644 --- a/docs/docs/report/template_editor.md +++ b/docs/docs/report/template_editor.md @@ -31,3 +31,10 @@ If you don't want to override the template, but just render a preview for a temp #### Edit template metadata Editing metadata such as name, description, filters and even width/height for labels and orientation/page size for reports can be done from the edit modal accessible when clicking on the three dots (4) and select "Edit" in the dropdown menu. + +### Editing Snippets + +[Report snippets](./index.md#report-snippets) can also be edited directly within the browser, from the *Report Snippets* panel in the Admin Center. Selecting a snippet from the table opens it in the same code editor as used for report and label templates. + +!!! info "No Preview" + As snippets cannot be rendered by themselves (they must be included in a report or label template), no preview area is available when editing a snippet. 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/backend/InvenTree/web/tests.py b/src/backend/InvenTree/web/tests.py index 04a25b73a1..41190526dc 100644 --- a/src/backend/InvenTree/web/tests.py +++ b/src/backend/InvenTree/web/tests.py @@ -30,7 +30,7 @@ class TemplateTagTest(InvenTreeTestCase): shipped_js = resp.split('