Source code for ferrosoft.apps.ferrobase.forms.copy

#  Copyright (c) 2025 Ferrosoft GmbH. All rights reserved.
from collections.abc import Callable
from dataclasses import dataclass
from typing import Iterable, Any, Protocol

from django import forms
from django.core.exceptions import ImproperlyConfigured
from django.forms.models import ModelFormMetaclass, ModelForm

PersistRelatedFunc = Callable[[], None]
"""Callable saving objects related to the instance being copied."""


class PreparatorFunc(Protocol):
    """Callable preparing a copied instance with custom logic.

    It can return an iterable of persisters, to save related objects after
    the copied instance is saved, to satisfy foreign key constraints."""

    def __call__(
        self, source: Any, target: Any, **kwargs
    ) -> Iterable[PersistRelatedFunc]: ...


[docs] def copy_fields(source, target, *, fields: Iterable[str], **kwargs): """Copy all named fields from source to target. Any keyword arguments passed to this function override values in target. Target is returned for convenience, allowing this function to be used in list comprehension.""" for field in fields: setattr(target, field, getattr(source, field)) for field, value in kwargs.items(): setattr(target, field, value) return target
@dataclass class ModelCopyingFormOptions: """Configuration for a ``ModelCopyingForm``. Attributes: fields_to_copy: Field names that are copied verbatim from the source instance to the new instance without user interaction. preparator: Optional callable for custom copy logic called after the standard field copy. May return an iterable of ``PersistRelatedFunc`` callables that are invoked after the new instance is saved to satisfy foreign-key constraints. """ fields_to_copy: list[str] preparator: PreparatorFunc | None = None
[docs] class ModelCopyingForm(forms.BaseModelForm, metaclass=ModelFormMetaclass): """Model form that creates a copy of an existing instance. The hidden ``source_id`` field identifies the source instance. On save, fields listed in ``_copying.fields_to_copy`` are transferred from source to the new instance; fields exposed in the form are filled in by the user and override the copied values. An optional ``preparator`` can implement additional copy logic (e.g. duplicating related objects). """ source_id = forms.UUIDField(widget=forms.HiddenInput()) @property def copy_options(self) -> ModelCopyingFormOptions: return getattr(self, "_copying")
[docs] def save(self, commit=True): opts = self.copy_options instance = super().save(commit=False) source = self._meta.model.objects.get(pk=self.cleaned_data["source_id"]) related_persisters = None copy_fields(source, instance, fields=opts.fields_to_copy) if opts.preparator is not None: related_persisters = opts.preparator( source, instance, cleaned_data=self.cleaned_data ) if commit: instance.save() # Invoke persisters of related objects after saving instance # to satisfy foreign key constraints. if related_persisters is not None: for persister in related_persisters: persister() return instance
def _modelform_factory( model, form=ModelForm, fields=None, exclude=None, formfield_callback=None, widgets=None, localized_fields=None, labels=None, help_texts=None, error_messages=None, field_classes=None, additional_fields=None, ): """ This is mostly a copy of Django modelform_factory, adding additional_fields parameter. If additional fields is a dict, it is added to the resulting form class. This can be used to add custom form fields. """ # Build up a list of attributes that the Meta object will have. attrs = {"model": model} if fields is not None: attrs["fields"] = fields if exclude is not None: attrs["exclude"] = exclude if widgets is not None: attrs["widgets"] = widgets if localized_fields is not None: attrs["localized_fields"] = localized_fields if labels is not None: attrs["labels"] = labels if help_texts is not None: attrs["help_texts"] = help_texts if error_messages is not None: attrs["error_messages"] = error_messages if field_classes is not None: attrs["field_classes"] = field_classes # If parent form class already has an inner Meta, the Meta we're # creating needs to inherit from the parent's inner meta. bases = (form.Meta,) if hasattr(form, "Meta") else () Meta = type("Meta", bases, attrs) if formfield_callback: Meta.formfield_callback = staticmethod(formfield_callback) # Give this new form class a reasonable name. class_name = model.__name__ + "Form" # Class attributes for the new form class. form_class_attrs = {"Meta": Meta} if isinstance(additional_fields, dict): form_class_attrs.update(additional_fields) if getattr(Meta, "fields", None) is None and getattr(Meta, "exclude", None) is None: raise ImproperlyConfigured( "Calling modelform_factory without defining 'fields' or " "'exclude' explicitly is prohibited." ) # Instantiate type(form) in order to use the same metaclass as form. return type(form)(class_name, (form,), form_class_attrs)
[docs] def copy_form_factory( model, *, additional_fields: dict[str, Any] | None = None, fields_to_copy: list | None = None, preparator: PreparatorFunc | None = None, **kwargs, ): """Create a model form class suitable for CopyView. Fields named in fields_to_copy are copied without user interaction from source to target instance. Pass preparator for custom copying logic. It is called with source and target objects after standard copying. Keyword arguments are passed to modelform_factory, allowing fields to be specified requiring user interaction.""" # noinspection PyTypeChecker model_class = _modelform_factory( model, form=ModelCopyingForm, additional_fields=additional_fields, **kwargs ) model_class._copying = ModelCopyingFormOptions(fields_to_copy or [], preparator) return model_class
__all__ = ["copy_fields", "copy_form_factory", "ModelCopyingForm"]