Repository: django-extensions/django-extensions

Issue:
RandomCharField (UniqueFieldMixin) not compatible with model with CheckConstraint
Hello,

I'm using django `Django==2.2.9`

I have a model with a `RandomCharField` and some extra constraint defined. 

```python

class MyModel(RandomSlugModel, TimeStampedModel):
    class Meta:
        unique_together = (("remote_field", "user"),)
        constraints = [
            CheckConstraint(
                check=Q(units_sold__lte=F('units_in_item')), name='check_sold',
            ),
            CheckConstraint(
                check=Q(units_reserved__lte=F('units_in_item')), name='check_reserved',
            ),
        ]
    [...]
```

If I try to save I get the following stack trace : 

```
web_1          |   File "/usr/local/lib/python3.8/site-packages/django_extensions/db/models.py", line 23, in save
web_1          |     super(TimeStampedModel, self).save(**kwargs)
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/base.py", line 740, in save
web_1          |     self.save_base(using=using, force_insert=force_insert,
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/base.py", line 777, in save_base
web_1          |     updated = self._save_table(
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/base.py", line 870, in _save_table
web_1          |     result = self._do_insert(cls._base_manager, using, fields, update_pk, raw)
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/base.py", line 907, in _do_insert
web_1          |     return manager._insert([self], fields=fields, return_id=update_pk,
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/manager.py", line 82, in manager_method
web_1          |     return getattr(self.get_queryset(), name)(*args, **kwargs)
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/query.py", line 1186, in _insert
web_1          |     return query.get_compiler(using=using).execute_sql(return_id)
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/sql/compiler.py", line 1367, in execute_sql
web_1          |     for sql, params in self.as_sql():
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/sql/compiler.py", line 1309, in as_sql
web_1          |     value_rows = [
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/sql/compiler.py", line 1310, in <listcomp>
web_1          |     [self.prepare_value(field, self.pre_save_val(field, obj)) for field in fields]
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/sql/compiler.py", line 1310, in <listcomp>
web_1          |     [self.prepare_value(field, self.pre_save_val(field, obj)) for field in fields]
web_1          |   File "/usr/local/lib/python3.8/site-packages/django/db/models/sql/compiler.py", line 1261, in pre_save_val
web_1          |     return field.pre_save(obj, add=True)
web_1          |   File "/usr/local/lib/python3.8/site-packages/django_extensions/db/fields/__init__.py", line 387, in pre_save
web_1          |     return self.find_unique(
web_1          |   File "/usr/local/lib/python3.8/site-packages/django_extensions/db/fields/__init__.py", line 74, in find_unique
web_1          |     if self.attname in constraint.fields:
web_1          | AttributeError: 'CheckConstraint' object has no attribute 'fields'
```

By reading the code at the pointed error ( https://github.com/django-extensions/django-extensions/blob/master/django_extensions/db/fields/__init__.py#L74 ) 
I would say it should be changed to : 
`               if self.attname in constraint.fields:`
to
`               if hasattr(constraint, 'fields') and self.attname in constraint.fields:`

It would help to support all type of constraint and not only the `UniqueConstraint` one : I think that constraint should be any of `BaseConstraint` that doesn't define a fields attribute.

Let me know if you need more details or would like a pull request with the suggested change above.
 
_In the meanwhile for those having the same issue a temporary workaround is to define a dummy class that wrap your constraint (in my case the CheckConstraint) and define a fields attribute._





Discussion:

Resolving patch:
diff --git a/django_extensions/db/fields/__init__.py b/django_extensions/db/fields/__init__.py
--- a/django_extensions/db/fields/__init__.py
+++ b/django_extensions/db/fields/__init__.py
@@ -23,7 +23,7 @@
 
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
-from django.db.models import DateTimeField, CharField, SlugField, Q
+from django.db.models import DateTimeField, CharField, SlugField, Q, UniqueConstraint
 from django.db.models.constants import LOOKUP_SEP
 from django.template.defaultfilters import slugify
 from django.utils.crypto import get_random_string
@@ -70,11 +70,14 @@ def find_unique(self, model_instance, field, iterator, *args):
         query = Q()
         constraints = getattr(model_instance._meta, 'constraints', None)
         if constraints:
-            for constraint in constraints:
-                if self.attname in constraint.fields:
+            unique_constraints = filter(
+                lambda c: isinstance(c, UniqueConstraint), constraints
+            )
+            for unique_constraint in unique_constraints:
+                if self.attname in unique_constraint.fields:
                     condition = {
                         field: getattr(model_instance, field, None)
-                        for field in constraint.fields
+                        for field in unique_constraint.fields
                         if field != self.attname
                     }
                     query &= Q(**condition)


Test patch:
diff --git a/tests/db/fields/test_uniq_field_mixin_compat.py b/tests/db/fields/test_uniq_field_mixin_compat.py
new file mode 100644
--- /dev/null
+++ b/tests/db/fields/test_uniq_field_mixin_compat.py
@@ -0,0 +1,156 @@
+# -*- coding: utf-8 -*-
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+
+import six
+from django.db import models
+from django.test import TestCase
+from tests.testapp.models import (
+    DummyRelationModel, InheritedFromPostWithUniqFieldCompat, PostWithUniqFieldCompat,
+    ReverseModelCompat, SecondDummyRelationModel, ThirdDummyRelationModel,
+)
+
+from django_extensions.db.fields import UniqueFieldMixin
+
+
+class UniqFieldMixinCompatTestCase(TestCase):
+    def setUp(self):
+
+        class MockField(UniqueFieldMixin):
+            def __init__(self, **kwargs):
+                for key, value in six.iteritems(kwargs):
+                    setattr(self, key, value)
+
+        self.uniq_field = MockField(
+            attname='uniq_field',
+            max_length=255,
+            boolean_attr=True,
+            non_boolean_attr='non_boolean_attr'
+        )
+
+        f_dummy = DummyRelationModel.objects.create()
+        s_dummy = SecondDummyRelationModel.objects.create()
+        t_dummy = ThirdDummyRelationModel.objects.create()
+
+        post = PostWithUniqFieldCompat.objects.create(
+            uniq_field='test_uniq',
+            common_field='first',
+            another_common_field='second',
+            many_to_one_field=f_dummy,
+            one_to_one_field=s_dummy,
+        )
+        post.many_to_many_field.add(t_dummy)
+        post.save()
+
+        ReverseModelCompat.objects.create(post_field=post)
+
+        self.post = post
+
+    def tearDown(self):
+        PostWithUniqFieldCompat.objects.all().delete()
+        DummyRelationModel.objects.all().delete()
+        SecondDummyRelationModel.objects.all().delete()
+        ThirdDummyRelationModel.objects.all().delete()
+        ReverseModelCompat.objects.all().delete()
+
+    def test_check_is_bool_boolean_attr(self):
+
+        self.assertIsNone(self.uniq_field.check_is_bool('boolean_attr'))
+
+    def test_check_is_bool_non_boolean_attr(self):
+        with self.assertRaisesMessage(
+                ValueError,
+                "'non_boolean_attr' argument must be True or False",
+        ):
+            self.uniq_field.check_is_bool('non_boolean_attr')
+
+    def test__get_fields_returns_list_of_tulpes(self):
+        uniq_mixin_fields = UniqueFieldMixin._get_fields(PostWithUniqFieldCompat)
+        self.assertIsInstance(uniq_mixin_fields, list)
+        for field in uniq_mixin_fields:
+            self.assertIsInstance(field, tuple)
+
+    def test__get_fields_returns_correct_fields(self):
+        option_fields = PostWithUniqFieldCompat._meta.get_fields()
+        uniq_mixin_fields = [i[0] for i in UniqueFieldMixin._get_fields(PostWithUniqFieldCompat)]
+
+        self.assertEqual(len(option_fields), 9)
+        self.assertEqual(len(uniq_mixin_fields), 7)
+
+        fields_to_be_excluded_from_uniq_mixin_fields = [
+            f for f in option_fields
+            if f.is_relation and not f.one_to_one and not (f.many_to_one and f.related_model)
+        ]
+
+        for field in fields_to_be_excluded_from_uniq_mixin_fields:
+            self.assertNotIn(field, uniq_mixin_fields)
+
+    def test__get_fields_returns_correct_model(self):
+        post_models = [i[1] for i in UniqueFieldMixin._get_fields(PostWithUniqFieldCompat)]
+        self.assertTrue(all(model is None for model in post_models))
+
+        inherited_post_models = [
+            i[1] for i
+            in UniqueFieldMixin._get_fields(InheritedFromPostWithUniqFieldCompat)
+            if i[1]
+        ]
+
+        self.assertEqual(len(inherited_post_models), 6)
+        self.assertTrue(all(model is PostWithUniqFieldCompat) for model in inherited_post_models)
+
+    def test_get_queryset(self):
+        mocked_get_fields = (
+            (models.CharField, PostWithUniqFieldCompat),
+        )
+        with mock.patch(
+                'django_extensions.db.fields.UniqueFieldMixin._get_fields',
+                return_value=mocked_get_fields
+        ), mock.patch(
+            'tests.testapp.models.PostWithUniqFieldCompat._default_manager.all'
+        ) as mocked_qs_all:
+
+            self.uniq_field.get_queryset(PostWithUniqFieldCompat, models.CharField)
+
+        mocked_qs_all.assert_called_with()
+
+        mocked_get_fields = (
+            (models.CharField, None),
+        )
+
+        with mock.patch(
+                'django_extensions.db.fields.UniqueFieldMixin._get_fields',
+                return_value=mocked_get_fields
+        ), mock.patch(
+            'tests.testapp.models.InheritedFromPostWithUniqFieldCompat._default_manager.all'
+        ) as mocked_qs_all:
+
+            self.uniq_field.get_queryset(InheritedFromPostWithUniqFieldCompat, models.CharField)
+
+        mocked_qs_all.assert_called_with()
+
+    def test_find_unique(self):
+        def filter_func(*args, **kwargs):
+            uniq_field = kwargs.get('uniq_field')
+            if uniq_field == 'a':
+                return mocked_qs
+            return None
+
+        mocked_qs = mock.Mock(spec=PostWithUniqFieldCompat.objects)
+        mocked_qs.filter.side_effect = filter_func
+        mocked_qs.exclude.return_value = mocked_qs
+
+        field = models.CharField
+        with mock.patch(
+                'django_extensions.db.fields.UniqueFieldMixin.get_queryset',
+                return_value=mocked_qs
+        ) as get_qs:
+
+            res = self.uniq_field.find_unique(self.post, field, iter('abcde'))
+
+        get_qs.assert_called_with(PostWithUniqFieldCompat, field)
+        mocked_qs.exclude.assert_called_with(pk=self.post.pk)
+        self.assertEqual(res, 'b')
+        self.assertTrue(hasattr(self.post, 'uniq_field'))
+        self.assertEqual(self.post.uniq_field, 'b')
diff --git a/tests/testapp/models.py b/tests/testapp/models.py
--- a/tests/testapp/models.py
+++ b/tests/testapp/models.py
@@ -100,7 +100,12 @@ class Meta:
         app_label = 'django_extensions'
 
 
-class PostWithUniqField(models.Model):
+class PostWithUniqFieldCompat(models.Model):
+    """
+    django-extensions from version 3.0 officially supports only Django 2.2+ which
+    introduced Meta.constraints and suggests using it instead of Meta.unique_together
+    this is left only to ensure compatibility with Meta.unique_together
+    """
     uniq_field = UniqField(
         max_length=255,
         boolean_attr=True,
@@ -117,6 +122,40 @@ class Meta:
         unique_together = ('common_field', 'uniq_field',)
 
 
+class ReverseModelCompat(models.Model):
+    post_field = models.ForeignKey(PostWithUniqFieldCompat, related_name='reverse_models', on_delete=models.CASCADE)
+
+    class Meta:
+        app_label = 'django_extensions'
+
+
+class InheritedFromPostWithUniqFieldCompat(PostWithUniqFieldCompat):
+    new_field = models.CharField(max_length=10)
+
+    class Meta:
+        app_label = 'django_extensions'
+
+
+class PostWithUniqField(models.Model):
+    uniq_field = UniqField(
+        max_length=255,
+        boolean_attr=True,
+        non_boolean_attr='non_boolean_attr'
+    )
+    common_field = models.CharField(max_length=10)
+    another_common_field = models.CharField(max_length=10)
+    many_to_one_field = models.ForeignKey(DummyRelationModel, on_delete=models.CASCADE)
+    one_to_one_field = models.OneToOneField(SecondDummyRelationModel, on_delete=models.CASCADE)
+    many_to_many_field = models.ManyToManyField(ThirdDummyRelationModel, related_name='posts22_with_uniq')
+
+    class Meta:
+        app_label = 'django_extensions'
+        constraints = [
+            models.UniqueConstraint(fields=('common_field', 'uniq_field'), name='unique_common_uniq_pair'),
+            models.CheckConstraint(check=~models.Q(common_field=models.F("another_common_field")), name='common_and_another_common_differ'),
+        ]
+
+
 class ReverseModel(models.Model):
     post_field = models.ForeignKey(PostWithUniqField, related_name='reverse_models', on_delete=models.CASCADE)