Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option to link existing objects of m2m fields instead of cloning them #752

Merged
merged 10 commits into from
Jan 30, 2023
78 changes: 78 additions & 0 deletions model_clone/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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(
[

Check warning

Code scanning / Pylint (reported by Codacy)

Wrong hanging indentation before block (add 4 spaces).

Wrong hanging indentation before block (add 4 spaces).
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
111 changes: 98 additions & 13 deletions model_clone/tests/test_clone_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Sentence,
Tag,
)
from sample_driver.models import Driver, DriverFlag

User = get_user_model()

Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
33 changes: 33 additions & 0 deletions sample_driver/migrations/0003_driverflag_driver_flags.py
Original file line number Diff line number Diff line change
@@ -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"),
),
]
10 changes: 10 additions & 0 deletions sample_driver/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]