Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6116787
Resolved bug cloning models with UniqueConstraint.
Jul 17, 2021
61d06d7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2021
b0d55cb
Fixed test.
Jul 17, 2021
ec5cce4
Merge branch 'feature/resolve-bug-cloning-models-with-unique-constrai…
Jul 17, 2021
1c94712
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2021
558b748
Fixed test.
Jul 17, 2021
5f416dd
Merge branch 'feature/resolve-bug-cloning-models-with-unique-constrai…
Jul 17, 2021
a2c0757
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2021
69654e1
Fixed test.
Jul 17, 2021
dcaa106
Merge branch 'feature/resolve-bug-cloning-models-with-unique-constrai…
Jul 17, 2021
a6fb5f7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2021
74a6343
Fixed test.
Jul 17, 2021
9953467
Merge branch 'feature/resolve-bug-cloning-models-with-unique-constrai…
Jul 17, 2021
0c58ed2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2021
e50d4fa
Updated test.
Jul 17, 2021
a57a9d9
Merge branch 'feature/resolve-bug-cloning-models-with-unique-constrai…
Jul 17, 2021
43aceea
Updated test.
Jul 18, 2021
d418ab2
Updated test.
Jul 18, 2021
7e15f89
Update utils.py
jackton1 Jul 18, 2021
9910189
Update test_clone_mixin.py
jackton1 Jul 18, 2021
ea7e07a
Update test_clone_mixin.py
jackton1 Jul 18, 2021
4e22321
Update test_clone_mixin.py
jackton1 Jul 18, 2021
20d24c9
Update test_clone_mixin.py
jackton1 Jul 18, 2021
20619ef
Update utils.py
jackton1 Jul 18, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion django_clone/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"sample_driver",
]

if sys.version_info >= (3, 6):
if sys.version_info >= (3, 6) and '--fix' in sys.argv:
INSTALLED_APPS += ["migration_fixer"]


Expand Down
28 changes: 24 additions & 4 deletions model_clone/mixins/clone.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import itertools
import warnings
from itertools import repeat
from typing import Dict, List, Optional

Expand All @@ -15,7 +16,7 @@
context_mutable_attribute,
get_fields_and_unique_fields_from_cls,
get_unique_value,
transaction_autocommit,
transaction_autocommit, get_unique_default,
)


Expand Down Expand Up @@ -341,12 +342,31 @@ def _create_copy_of_instance(instance, using=None, force=False, sub_clone=False)
and not f.choices
):
value = clean_value(value, unique_duplicate_suffix)
if use_unique_duplicate_suffix:

if f.has_default():
value = f.get_default()

if not callable(f.default):
value = get_unique_default(
model=cls,
fname=f.attname,
value=value,
transform=(
slugify if isinstance(f, SlugField) else str
),
suffix=unique_duplicate_suffix,
max_length=f.max_length,
max_attempts=max_unique_duplicate_query_attempts,
)

elif use_unique_duplicate_suffix:
value = get_unique_value(
obj=instance,
model=cls,
fname=f.attname,
value=value,
transform=(slugify if isinstance(f, SlugField) else str),
transform=(
slugify if isinstance(f, SlugField) else str
),
suffix=unique_duplicate_suffix,
max_length=f.max_length,
max_attempts=max_unique_duplicate_query_attempts,
Expand Down
49 changes: 43 additions & 6 deletions model_clone/tests/test_clone_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,26 @@ def test_cloning_without_explicit_clone_m2m_fields(self):
list(book_clone.authors.values_list("first_name", "last_name")),
)

def test_cloning_with_unique_constraint_is_valid(self):
sale_tag = SaleTag.objects.create(name="test-sale-tag")
clone_sale_tag = sale_tag.make_clone()

self.assertNotEqual(sale_tag.pk, clone_sale_tag.pk)
self.assertRegexpMatches(
clone_sale_tag.name,
r"{}\s[\d]".format(SaleTag.UNIQUE_DUPLICATE_SUFFIX),
)

def test_cloning_with_unique_constraint_uses_field_default(self):
tag = Tag.objects.create(name="test-tag")
clone_tag = tag.make_clone()

self.assertNotEqual(tag.pk, clone_tag.pk)
self.assertRegexpMatches(
clone_tag.name,
r"\s[\d]",
)

@patch("sample.models.Book._clone_m2m_fields", new_callable=PropertyMock)
def test_cloning_with_explicit_clone_m2m_fields(
self,
Expand Down Expand Up @@ -416,17 +436,34 @@ def test_cloning_unique_together_fields_with_enum_field(self):
created_by=self.user1,
)

author_clone = author.make_clone()
author_clone_1 = author.make_clone()

self.assertNotEqual(author.pk, author_clone.pk)
self.assertEqual(author.sex, author_clone.sex)
self.assertNotEqual(author.pk, author_clone_1.pk)
self.assertEqual(author.sex, author_clone_1.sex)
self.assertEqual(
author_clone.first_name,
author_clone_1.first_name,
"{} {} {}".format(first_name, Author.UNIQUE_DUPLICATE_SUFFIX, 1),
)
self.assertEqual(
author_clone.last_name,
"{} {} {}".format(last_name, Author.UNIQUE_DUPLICATE_SUFFIX, 1),
author_clone_1.last_name,
Author._meta.get_field('last_name').get_default(),
)

author_clone_2 = author.make_clone()

self.assertNotEqual(author.pk, author_clone_2.pk)
self.assertEqual(author.sex, author_clone_2.sex)
self.assertEqual(
author_clone_2.first_name,
"{} {} {}".format(first_name, Author.UNIQUE_DUPLICATE_SUFFIX, 2),
)
self.assertEqual(
author_clone_2.last_name,
"{} {} {}".format(
Author._meta.get_field('last_name').get_default(),
Author.UNIQUE_DUPLICATE_SUFFIX,
1
),
)

def test_cloning_unique_slug_field(self):
Expand Down
72 changes: 68 additions & 4 deletions model_clone/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ def create_copy_of_instance(
return new_obj


def unpack_unique_constraints(opts, only_fields=()):
"""
Unpack unique constraint fields.

:param opts: Model options
:type opts: `django.db.models.options.Options`
:param only_fields: Fields that should be considered.
:type only_fields: `collections.Iterable`
:return: Flat list of fields.
"""
fields = []
for constraint in opts.total_unique_constraints:
fields.extend([f for f in constraint.fields if f in only_fields])
return fields


def unpack_unique_together(opts, only_fields=()):
"""
Unpack unique together fields.
Expand Down Expand Up @@ -163,7 +179,7 @@ def get_value(value, suffix, transform, max_length, index):
Append a suffix to a string value and apply a pass directly to a
transformation function.
"""
duplicate_suffix = " {} {}".format(suffix, index)
duplicate_suffix = " " + "{} {}".format(suffix, index).strip()
total_length = len(value + duplicate_suffix)

if max_length is not None and total_length > max_length:
Expand All @@ -189,12 +205,20 @@ def generate_value(value, suffix, transform, max_length, max_attempts):
)


def get_unique_value(obj, fname, value, transform, suffix, max_length, max_attempts):
def get_unique_value(
model,
fname,
value='',
transform=lambda v: v,
suffix='',
max_length=None,
max_attempts=100,
):
"""
Generate a unique value using current value and query the model
for existing objects with the new value.
"""
qs = obj.__class__._default_manager.all()
qs = model._default_manager.all()
it = generate_value(value, suffix, transform, max_length, max_attempts)

new = six.next(it)
Expand Down Expand Up @@ -251,10 +275,50 @@ def get_fields_and_unique_fields_from_cls(
only_fields=[f.attname for f in fields],
)

unique_constraint_field_names = unpack_unique_constraints(
opts=cls._meta,
only_fields=[f.attname for f in fields],
)

unique_fields = [
f.name
for f in fields
if not f.auto_created and (f.unique or f.name in unique_field_names)
if not f.auto_created and (f.unique or f.name in unique_field_names
or f.name in unique_constraint_field_names)
]

return fields, unique_fields


def get_unique_default(
model,
fname,
value,
transform=lambda v: v,
suffix='',
max_length=None,
max_attempts=100,
):
"""Get a unique value using the value and adding a suffix if needed."""

qs = model._default_manager.all()

if not qs.filter(**{fname: value}).exists():
return value

it = generate_value(
value,
suffix,
transform,
max_length,
max_attempts,
)

new = six.next(it)
kwargs = {fname: new}

while qs.filter(**kwargs).exists():
new = six.next(it)
kwargs[fname] = new

return new
18 changes: 18 additions & 0 deletions sample/migrations/0019_saletag_sale_tag_unique_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2021-07-17 20:26

import django
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('sample', '0018_auto_20210628_2301'),
]

operations = [
migrations.AddConstraint(
model_name='saletag',
constraint=models.UniqueConstraint(fields=('name',), name='sale_tag_unique_name'),
),
] if django.VERSION >= (2, 2) else []
23 changes: 23 additions & 0 deletions sample/migrations/0020_auto_20210717_2230.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.5 on 2021-07-17 22:30
import django
from django.db import migrations, models
import sample.models


class Migration(migrations.Migration):

dependencies = [
('sample', '0019_saletag_sale_tag_unique_name'),
]

operations = [
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(default=sample.models.get_unique_tag_name, max_length=255),
),
migrations.AddConstraint(
model_name='tag',
constraint=models.UniqueConstraint(fields=('name',), name='tag_unique_name'),
),
] if django.VERSION >= (2, 2) else []
27 changes: 26 additions & 1 deletion sample/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
from itertools import count
from uuid import uuid4

import django
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext as _

from model_clone.utils import get_unique_default

if django.VERSION >= (2, 2):
from django.db.models import UniqueConstraint

from model_clone import CloneMixin
from model_clone.models import CloneModel

Expand Down Expand Up @@ -38,8 +45,21 @@ class Meta:
unique_together = (("first_name", "last_name", "sex"),)


def get_unique_tag_name():
return get_unique_default(
model=Tag,
fname='name',
value='test-tag',
)


class Tag(CloneModel):
name = models.CharField(max_length=255)
name = models.CharField(max_length=255, default=get_unique_tag_name)

class Meta:
constraints = [
UniqueConstraint(fields=['name'], name='tag_unique_name'),
] if django.VERSION >= (2, 2) else []

def __str__(self):
return _(self.name)
Expand All @@ -48,6 +68,11 @@ def __str__(self):
class SaleTag(CloneModel):
name = models.CharField(max_length=255)

class Meta:
constraints = [
UniqueConstraint(fields=['name'], name='sale_tag_unique_name'),
] if django.VERSION >= (2, 2) else []

def __str__(self):
return _(self.name)

Expand Down