Search code examples
djangodjango-rest-frameworkpytestdjango-serializerunique-key

How do I create a serializer that reuses a unique key of my model?


I'm using Python 3.7, Django 2.2, the Django rest framework, and pytest. I have the following model, in which I want to re-use an existing model if it exists by its unique key ...

class CoopTypeManager(models.Manager):

    def get_by_natural_key(self, name):
        return self.get_or_create(name=name)[0]

class CoopType(models.Model):
    name = models.CharField(max_length=200, null=False, unique=True)

    objects = CoopTypeManager()

Then I have created the below serializer to generate this model from REST data

class CoopTypeSerializer(serializers.ModelSerializer):
    class Meta:
        model = CoopType
        fields = ['id', 'name']

    def create(self, validated_data):
        """
        Create and return a new `CoopType` instance, given the validated data.
        """
        return CoopType.objects.get_or_create(**validated_data)

    def update(self, instance, validated_data):
        """
        Update and return an existing `CoopType` instance, given the validated data.
        """
        instance.name = validated_data.get('name', instance.name)
        instance.save()
        return instance

However, when I run the below test in which I intentionally use a name that is taken

@pytest.mark.django_db
def test_coop_type_create_with_existing(self):
    """ Test coop type serizlizer model if there is already a coop type by that name """
    coop_type = CoopTypeFactory()
    serializer_data = {
        "name": coop_type.name,
    }

    serializer = CoopTypeSerializer(data=serializer_data)
    serializer.is_valid()
    print(serializer.errors)
    assert serializer.is_valid(), serializer.errors
    result = serializer.save()
    assert result.name == name

I get the below error

python manage.py test --settings=directory.test_settings
...        ----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/davea/Documents/workspace/chicommons/maps/web/tests/test_serializers.py", line 46, in test_coop_type_create_with_existing
    assert serializer.is_valid(), serializer.errors
AssertionError: {'name': [ErrorDetail(string='coop type with this name already exists.', code='unique')]}

How do I construct my serializer so that I can create my model if its unique key doesn't exist, or re-use it if it does?

Edit: Here's the GitHub link ...

https://github.com/chicommons/maps/tree/master/web

Solution

  • DRF validates the uniqueness of each field if is declared with unique=True in the model, so you have to change the model as following if you want to keep your unique contraint for the name field:

    class CoopType(models.Model):
        name = models.CharField(max_length=200, null=False)
    
        objects = CoopTypeManager()
    
        class Meta:
            # Creates a new unique constraint with the `name` field
            constraints = [models.UniqueConstraint(fields=['name'], name='coop_type_unq')]
    

    Also, you have to change your serializer, if you're using a ViewSet with the default behavior, you only need to add a custom validation in the serializer.

    from rest_framework import serializers
    from rest_framework.exceptions import ValidationError
    
    from .models import CoopType
    
    
    class CoopTypeSerializer(serializers.ModelSerializer):
        default_error_messages = {'name_exists': 'The name already exists'}
    
        class Meta:
            model = CoopType
            fields = ['id', 'name']
    
        def validate(self, attrs):
            validated_attrs = super().validate(attrs)
            errors = {}
    
            # check if the new `name` doesn't exist for other db record, this is only for updates
            if (
                self.instance  # the instance to be updated
                and 'name' in validated_attrs  # if name is in the attributes
                and self.instance.name != validated_attrs['name']  # if the name is updated
            ):
                if (
                    CoopType.objects.filter(name=validated_attrs['name'])
                    .exclude(id=self.instance.id)
                    .exists()
                ):
                    errors['name'] = self.error_messages['name_exists']
    
            if errors:
                raise ValidationError(errors)
    
            return validated_attrs
    
        def create(self, validated_data):
            # get_or_create returns a tuple with (instance, boolean). The boolean is True if a new instance was created and False otherwise
            return CoopType.objects.get_or_create(**validated_data)[0]
    

    The update method was removed because is not needed.

    Finally, the tests:

    class FactoryTest(TestCase):
    
        def test_coop_type_create_with_existing(self):
            """ Test coop type serializer model if there is already a coop type by that name """
            coop_type = CoopTypeFactory()
            serializer_data = {
                "name": coop_type.name,
            }
    
            # Creation
            serializer = CoopTypeSerializer(data=serializer_data)
            serializer.is_valid()
            self.assertTrue(serializer.is_valid(), serializer.errors)
            result = serializer.save()
            assert result.name == serializer_data['name']
    
            # update with no changes
            serializer = CoopTypeSerializer(coop_type, data=serializer_data)
            serializer.is_valid()
            serializer.save()
            self.assertTrue(serializer.is_valid(), serializer.errors)
    
            # update with the name changed
            serializer = CoopTypeSerializer(coop_type, data={'name': 'testname'})
            serializer.is_valid()
            serializer.save()
            self.assertTrue(serializer.is_valid(), serializer.errors)
            coop_type.refresh_from_db()
            self.assertEqual(coop_type.name, 'testname')