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

牧云@^-^@ 提交于 2021-02-07 10:36:56

问题


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

回答1:


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')



回答2:


When you are using unique=True key in model, Serializer will automaticly add unique validator to that field. It’s enough to cancel the uniqueness check by writting your own name field directly in serializer to prevent your curent error:

class Ser(serializers.ModelSerializer):
    name = serializers.CharField()  # no unique validation here

    class Meta:
        model = CoopType
        fields = ['id', 'name']

    def create(self, validated_data):
        return CoopType.objects.get_or_create(**validated_data)

Be carefull: get_or_create in create method will return tuple, not instance.

Ok, now imagine you will call it with id field too so you really need an update method. Then you can make the following hack in validate method (maybe it's dirty, but it will work):

class Ser(serializers.ModelSerializer):
    # no `read_only` option (default for primary keys in `ModelSerializer`)
    id = serializers.IntegerField(required=False)

    # no unique validators in charfield
    name = serializers.CharField()

    class Meta:
        model = CoopType
        fields = ["id", "name"]

    def validate(self, attrs):
        attrs = super().validate(attrs)

        if "id" in attrs:
            try:
                self.instance = CoopType.objects.get(name=attrs["name"])
            except CoopType.DoesNotExist:
                pass

            # to prevent manual changing ids in database
            del attrs["id"]

        return attrs

    def create(self, validated_data):
        return CoopType.objects.get_or_create(**validated_data)

    def update(self, instance, validated_data):
        # you can delete that method, it will be called anyway from parent class
        return super().update(instance, validated_data)

The save method on the serializer checks if the field self.instance is null or not. If there is an non-empty self.instance, it will call the update method; else - create method.
So if CoopType with name from your serializer_data dictionary exists - update method will be called. In other case you will see create method call.




回答3:


My suggestion is to not use a ModelSerializer but instead use a vanilla serializer.

class CoopTypeSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    name = serializers.CharField(max_length=200, required=True, allow_blank=False)

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

    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


来源:https://stackoverflow.com/questions/62010284/how-do-i-create-a-serializer-that-reuses-a-unique-key-of-my-model

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!