Repository: tj-django/django-clone

Issue:
[Feature] Create m2m relations with existing objects new.m2mfield.set(old.m2mfield.all())
### Is this feature missing in the latest version?

- [X] I'm using the latest release

### Is your feature request related to a problem? Please describe.

See: https://github.com/tj-django/django-clone/issues/701

### Describe the solution you'd like?

Setting to specify how m2m objects should be cloned.

### Describe alternatives you've considered?

_No response_

### Anything else?

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
Thanks for reporting this issue, don't forget to star this project if you haven't already to help us reach a wider audience.
@jackton1 Hi there, thanks for creating this library! Have you started work on this feature yet? If not, I'd be interested in taking a swing at it.
Hi @DanielSchaffer, I haven’t and I’ll be happy to review a PR that resolves the issue
Alright, before I get started, this is what I was thinking for the implementation:

- Add a new  `_clone_linked_m2m_fields` attribute
  - Intended to extend the existing naming pattern for configuration attributes in a way that could later be further extend for other relations like m2o
  - Can be defined with `_clone_m2m_fields`, but not `_clone_excluded_m2m_fields`
- Update existing checks related to `_clone_m2m_fields` and `_clone_excluded_m2m_fields` to include `_clone_linked_m2m_fields`
- Add check to raise an error if `_clone_linked_m2m_fields` includes m2m fields with a `through` model - these can't be linked since they'd require a FK to the duplicate, so the existing m2m cloning already effectively creates a link to the target instead of a clone
- Add `__duplicate_m2m_linked_fields` method - basically the same as `__duplicate_m2m_fields`, except:
  - Use `_clone_linked_m2m_fields` instead of `_clone_m2m_fields` when building `fields`
  - call `destination.set(list(source.all()))` instead of iterating and cloning `source.all()`
- In `make_clone`, add a call to `__duplicate_m2m_linked_fields`
- Unit tests, of course!

It might make more sense to use `_link` as the prefix for these instead of `_clone`, but I wasn't sure if you'd intended to have `_clone` as a general prefix for the attributes added by the library - I don't have a strong opinion either way.

Resolving patch:
diff --git a/model_clone/mixin.py b/model_clone/mixin.py
--- a/model_clone/mixin.py
+++ b/model_clone/mixin.py
@@ -89,6 +89,9 @@ class CloneMixin(object):
     _clone_m2o_or_o2m_fields = []  # type: List[str]
     _clone_o2o_fields = []  # type: List[str]
 
+    # Included / linked (not copied) fields
+    _clone_linked_m2m_fields = []  # type: List[str]
+
     # Excluded fields
     _clone_excluded_fields = []  # type: List[str]
     _clone_excluded_m2m_fields = []  # type: List[str]
@@ -188,6 +191,58 @@ def check(cls, **kwargs):  # pragma: no cover
                 )
             )
 
+        errors.extend(cls.__check_has_invalid_linked_m2m_fields())
+
+        return errors
+
+    @classmethod
+    def __check_has_invalid_linked_m2m_fields(cls):
+        errors = []
+
+        for field_name in cls._clone_linked_m2m_fields:
+            field = cls._meta.get_field(field_name)
+            through_field = getattr(field, "field", getattr(field, "remote_field"))
+            through_model = through_field.through
+            if not through_model._meta.auto_created:
+                errors.append(
+                    Error(
+                        f"Invalid configuration for _clone_linked_m2m_fields: {field_name}",
+                        hint=(
+                            'Use "_clone_m2m_fields" instead of "_clone_linked_m2m_fields"'
+                            f" for m2m fields that are not auto-created for model {cls.__name__}"
+                        ),
+                        obj=cls,
+                        id=f"{ModelCloneConfig.name}.E003",
+                    )
+                )
+
+            if field_name in cls._clone_excluded_m2m_fields:
+                errors.append(
+                    Error(
+                        f"Invalid configuration for _clone_excluded_m2m_fields: {field_name}",
+                        hint=(
+                            "Fields that are linked with _clone_linked_m2m_fields "
+                            f"cannot be excluded in _clone_excluded_m2m_fields for model "
+                            f"{cls.__name__}"
+                        ),
+                        obj=cls,
+                        id=f"{ModelCloneConfig.name}.E002",
+                    )
+                )
+
+            if field_name in cls._clone_m2m_fields:
+                errors.append(
+                    Error(
+                        f"Invalid configuration for _clone_m2m_fields: {field_name}",
+                        hint=(
+                            "Fields that are linked with _clone_linked_m2m_fields "
+                            f"cannot be included in _clone_m2m_fields for model {cls.__name__}"
+                        ),
+                        obj=cls,
+                        id=f"{ModelCloneConfig.name}.E002",
+                    )
+                )
+
         return errors
 
     @transaction.atomic
@@ -231,6 +286,7 @@ def make_clone(self, attrs=None, sub_clone=False, using=None, parent=None):
         duplicate = self.__duplicate_o2o_fields(duplicate, using=using)
         duplicate = self.__duplicate_o2m_fields(duplicate, using=using)
         duplicate = self.__duplicate_m2m_fields(duplicate, using=using)
+        duplicate = self.__duplicate_linked_m2m_fields(duplicate)
 
         post_clone_save.send(sender=self.__class__, instance=duplicate)
 
@@ -651,3 +707,25 @@ def __duplicate_m2m_fields(self, duplicate, using=None):
                 destination.set(items_clone)
 
         return duplicate
+
+    def __duplicate_linked_m2m_fields(self, duplicate):
+        """Duplicate many to many fields.
+
+        :param duplicate: The transient instance that should be duplicated.
+        :type duplicate: `django.db.models.Model`
+        :return: The duplicate instance objects from all the many-to-many fields duplicated.
+        """
+
+        for field in self._meta.many_to_many:
+            if all(
+                [
+                    field.attname not in self._clone_m2m_fields,
+                    field.attname not in self._clone_excluded_m2m_fields,
+                    field.attname in self._clone_linked_m2m_fields,
+                ]
+            ):
+                source = getattr(self, field.attname)
+                destination = getattr(duplicate, field.attname)
+                destination.set(list(source.all()))
+
+        return duplicate
diff --git a/sample_driver/migrations/0003_driverflag_driver_flags.py b/sample_driver/migrations/0003_driverflag_driver_flags.py
new file mode 100644
--- /dev/null
+++ b/sample_driver/migrations/0003_driverflag_driver_flags.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.1.5 on 2023-01-27 19:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("sample_driver", "0002_alter_driver_id"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="DriverFlag",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=255, unique=True)),
+            ],
+        ),
+        migrations.AddField(
+            model_name="driver",
+            name="flags",
+            field=models.ManyToManyField(to="sample_driver.driverflag"),
+        ),
+    ]
diff --git a/sample_driver/models.py b/sample_driver/models.py
--- a/sample_driver/models.py
+++ b/sample_driver/models.py
@@ -3,6 +3,16 @@
 from model_clone import CloneMixin
 
 
+class DriverFlag(models.Model):
+    name = models.CharField(max_length=255, unique=True)
+
+    def __str__(self):
+        return self.name
+
+
 class Driver(CloneMixin, models.Model):
     name = models.CharField(max_length=255)
     age = models.SmallIntegerField()
+    flags = models.ManyToManyField(DriverFlag)
+
+    _clone_linked_m2m_fields = ["flags"]


Test patch:
diff --git a/model_clone/tests/test_clone_mixin.py b/model_clone/tests/test_clone_mixin.py
--- a/model_clone/tests/test_clone_mixin.py
+++ b/model_clone/tests/test_clone_mixin.py
@@ -32,6 +32,7 @@
     Sentence,
     Tag,
 )
+from sample_driver.models import Driver, DriverFlag
 
 User = get_user_model()
 
@@ -598,7 +599,7 @@ def test_cloning_unique_slug_field(self):
 
         self.assertEqual(
             book_clone.slug,
-            slugify("{} {} {}".format(book.slug, Book.UNIQUE_DUPLICATE_SUFFIX, 1)),
+            slugify(f"{book.slug} {Book.UNIQUE_DUPLICATE_SUFFIX} {1}"),
         )
 
     def test_making_sub_clones_of_a_unique_slug_field(self):
@@ -610,7 +611,7 @@ def test_making_sub_clones_of_a_unique_slug_field(self):
 
         self.assertEqual(
             book_clone.slug,
-            slugify("{} {} {}".format(book.slug, Book.UNIQUE_DUPLICATE_SUFFIX, 1)),
+            slugify(f"{book.slug} {Book.UNIQUE_DUPLICATE_SUFFIX} {1}"),
         )
 
         for i in range(2, 7):
@@ -619,7 +620,7 @@ def test_making_sub_clones_of_a_unique_slug_field(self):
 
             self.assertEqual(
                 book_clone.slug,
-                slugify("{} {} {}".format(book.slug, Book.UNIQUE_DUPLICATE_SUFFIX, i)),
+                slugify(f"{book.slug} {Book.UNIQUE_DUPLICATE_SUFFIX} {i}"),
             )
 
     @patch(
@@ -853,6 +854,43 @@ def test_cloning_complex_model_relationships(self):
         self.assertEqual(house.name, clone_house.name)
         self.assertEqual(house.rooms.count(), clone_house.rooms.count())
 
+    def test_cloning_linked_m2m(self):
+        driver = Driver.objects.create(name="Dave", age=42)
+        driver.flags.set(
+            [
+                DriverFlag.objects.create(name="awesome"),
+                DriverFlag.objects.create(name="usually_on_time"),
+                DriverFlag.objects.create(name="great_hats"),
+            ]
+        )
+
+        clone_driver = driver.make_clone()
+
+        self.assertEqual(driver.name, clone_driver.name)
+        self.assertEqual(driver.age, clone_driver.age)
+        self.assertEqual(driver.flags.count(), clone_driver.flags.count())
+        self.assertSequenceEqual(
+            list(driver.flags.order_by("name")),
+            list(clone_driver.flags.order_by("name")),
+        )
+        self.assertEqual(
+            driver.flags.order_by("name").first().id,
+            clone_driver.flags.order_by("name").first().id,
+        )
+
+    def test_cloning_o2o_fields(self):
+        sentence = Sentence.objects.create(value="A really long sentence")
+        Ending.objects.create(sentence=sentence)
+
+        self.assertEqual(1, Sentence.objects.count())
+        self.assertEqual(1, Ending.objects.count())
+
+        clones = [sentence.make_clone() for _ in range(2)]
+
+        self.assertEqual(2, len(clones))
+        self.assertEqual(3, Sentence.objects.count())
+        self.assertEqual(3, Ending.objects.count())
+
     @patch(
         "sample.models.Edition.USE_UNIQUE_DUPLICATE_SUFFIX",
         new_callable=PropertyMock,
@@ -1001,18 +1039,65 @@ def test_clone_o2o_fields_check(
         ]
         self.assertEqual(errors, expected_errors)
 
-    def test_cloning_o2o_fields(self):
-        sentence = Sentence.objects.create(value="A really long sentence")
-        Ending.objects.create(sentence=sentence)
-
-        self.assertEqual(1, Sentence.objects.count())
-        self.assertEqual(1, Ending.objects.count())
+    @patch(
+        "sample_driver.models.Driver._clone_excluded_m2m_fields",
+        new_callable=PropertyMock,
+        return_value=["flags"],
+    )
+    def test_clone_linked_m2m_fields_excluded_check(self, _):
+        errors = Driver.check()
+        expected_errors = [
+            Error(
+                "Invalid configuration for _clone_excluded_m2m_fields: flags",
+                hint=(
+                    "Fields that are linked with _clone_linked_m2m_fields "
+                    f"cannot be excluded in _clone_excluded_m2m_fields for model {Driver.__name__}"
+                ),
+                obj=Driver,
+                id="{}.E002".format(ModelCloneConfig.name),
+            )
+        ]
+        self.assertEqual(errors, expected_errors)
 
-        clones = [sentence.make_clone() for _ in range(2)]
+    @patch(
+        "sample_driver.models.Driver._clone_m2m_fields",
+        new_callable=PropertyMock,
+        return_value=["flags"],
+    )
+    def test_clone_linked_m2m_fields_redundant_check(self, _):
+        errors = Driver.check()
+        expected_errors = [
+            Error(
+                "Invalid configuration for _clone_m2m_fields: flags",
+                hint=(
+                    "Fields that are linked with _clone_linked_m2m_fields "
+                    f"cannot be included in _clone_m2m_fields for model {Driver.__name__}"
+                ),
+                obj=Driver,
+                id="{}.E002".format(ModelCloneConfig.name),
+            )
+        ]
+        self.assertEqual(errors, expected_errors)
 
-        self.assertEqual(2, len(clones))
-        self.assertEqual(3, Sentence.objects.count())
-        self.assertEqual(3, Ending.objects.count())
+    @patch(
+        "sample.models.Book._clone_linked_m2m_fields",
+        new_callable=PropertyMock,
+        return_value=["tags"],
+    )
+    def test_clone_linked_m2m_invalid_through_check(self, _):
+        errors = Book.check()
+        expected_errors = [
+            Error(
+                "Invalid configuration for _clone_linked_m2m_fields: tags",
+                hint=(
+                    'Use "_clone_m2m_fields" instead of "_clone_linked_m2m_fields"'
+                    f" for m2m fields that are not auto-created for model {Book.__name__}"
+                ),
+                obj=Book,
+                id="{}.E003".format(ModelCloneConfig.name),
+            )
+        ]
+        self.assertEqual(errors, expected_errors)
 
 
 class CloneMixinTransactionTestCase(TransactionTestCase):