Django Rest Framework with ChoiceField

2019-01-21 08:08发布

问题:

I have a few fields in my user model that are choice fields and am trying to figure out how to best implement that into Django Rest Framework.

Below is some simplified code to show what I'm doing.

# models.py
class User(AbstractUser):
    GENDER_CHOICES = (
        ('M', 'Male'),
        ('F', 'Female'),
    )

    gender = models.CharField(max_length=1, choices=GENDER_CHOICES)


# serializers.py 
class UserSerializer(serializers.ModelSerializer):
    gender = serializers.CharField(source='get_gender_display')

    class Meta:
        model = User


# viewsets.py
class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer

Essentially what I'm trying to do is to have the get/post/put methods use the display value of the choice field instead of the code, looking something like the below JSON.

{
  'username': 'newtestuser',
  'email': 'newuser@email.com',
  'first_name': 'first',
  'last_name': 'last',
  'gender': 'Male'
  // instead of 'gender': 'M'
}

How would I go about doing that? The above code does not work. Before I had something like this working for GET, but for POST/PUT it was giving me errors. I'm looking for general advice on how to do this, it seems like it would be something common, but I can't find examples. Either that or I'm doing something terribly wrong.

回答1:

Django provides the Model.get_FOO_display method to get the "human-readable" value of a field:

class UserSerializer(serializers.ModelSerializer):
    gender = serializers.SerializerMethodField()

    class Meta:
        model = User

    def get_gender(self,obj):
        return obj.get_gender_display()

for the latest DRF (3.6.3) - easiest method is:

gender = serializers.CharField(source='get_gender_display')


回答2:

I suggest to use django-models-utils with a custom DRF serializer field

Code becomes:

# models.py
from model_utils import Choices

class User(AbstractUser):
    GENDER = Choices(
       ('M', 'Male'),
       ('F', 'Female'),
    )

    gender = models.CharField(max_length=1, choices=GENDER, default=GENDER.M)


# serializers.py 
from rest_framework import serializers

class ChoicesField(serializers.Field):
    def __init__(self, choices, **kwargs):
        self._choices = choices
        super(ChoicesField, self).__init__(**kwargs)

    def to_representation(self, obj):
        return self._choices[obj]

    def to_internal_value(self, data):
        return getattr(self._choices, data)

class UserSerializer(serializers.ModelSerializer):
    gender = ChoicesField(choices=User.GENDER)

    class Meta:
        model = User

# viewsets.py
class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer


回答3:

Probalbly you need something like this somewhere in your util.py and import in whichever serializers ChoiceFields are involved.

class ChoicesField(serializers.Field):
    """Custom ChoiceField serializer field."""

    def __init__(self, choices, **kwargs):
        """init."""
        self._choices = OrderedDict(choices)
        super(ChoicesField, self).__init__(**kwargs)

    def to_representation(self, obj):
        """Used while retrieving value for the field."""
        return self._choices[obj]

    def to_internal_value(self, data):
        """Used while storing value for the field."""
        for i in self._choices:
            if self._choices[i] == data:
                return i
        raise serializers.ValidationError("Acceptable values are {0}.".format(list(self._choices.values())))


回答4:

The following solution works with any field with choices, with no need to specify in the serializer a custom method for each:

from rest_framework import serializers

class ChoicesSerializerField(serializers.SerializerMethodField):
    """
    A read-only field that return the representation of a model field with choices.
    """

    def to_representation(self, value):
        # sample: 'get_XXXX_display'
        method_name = 'get_{field_name}_display'.format(field_name=self.field_name)
        # retrieve instance method
        method = getattr(value, method_name)
        # finally use instance method to return result of get_XXXX_display()
        return method()

Example:

given:

class Person(models.Model):
    ...
    GENDER_CHOICES = (
        ('M', 'Male'),
        ('F', 'Female'),
    )
    gender = models.CharField(max_length=1, choices=GENDER_CHOICES)

use:

class PersonSerializer(serializers.ModelSerializer):
    ...
    gender = ChoicesSerializerField()

to receive:

{
    ...
    'gender': 'Male'
}

instead of:

{
    ...
    'gender': 'M'
}


回答5:

Since DRF 3.1 there is new API called customizing field mapping. I used it to change default ChoiceField mapping to ChoiceDisplayField:

import six
from rest_framework.fields import ChoiceField


class ChoiceDisplayField(ChoiceField):
    def __init__(self, *args, **kwargs):
        super(ChoiceDisplayField, self).__init__(*args, **kwargs)
        self.choice_strings_to_display = {
            six.text_type(key): value for key, value in self.choices.items()
        }

    def to_representation(self, value):
        if value is None:
            return value
        return {
            'value': self.choice_strings_to_values.get(six.text_type(value), value),
            'display': self.choice_strings_to_display.get(six.text_type(value), value),
        }

class DefaultModelSerializer(serializers.ModelSerializer):
    serializer_choice_field = ChoiceDisplayField

If You use DefaultModelSerializer:

class UserSerializer(DefaultModelSerializer):    
    class Meta:
        model = User
        fields = ('id', 'gender')

You will get something like:

...

"id": 1,
"gender": {
    "display": "Male",
    "value": "M"
},
...


回答6:

I found soup boy's approach to be the best. Though I'd suggest to inherit from serializers.ChoiceField rather than serializers.Field. This way you only need to override to_representation method and the rest works like a regular ChoiceField.

class DisplayChoiceField(serializers.ChoiceField):

    def __init__(self, *args, **kwargs):
        choices = kwargs.get('choices')
        self._choices = OrderedDict(choices)
        super(DisplayChoiceField, self).__init__(*args, **kwargs)

    def to_representation(self, obj):
        """Used while retrieving value for the field."""
        return self._choices[obj]


回答7:

I prefer the answer by @nicolaspanel to keep the field writeable. If you use this definition instead of his ChoiceField, you take advantage of any/all of the infrastructure in the built-in ChoiceField while mapping the choices from str => int:

class MappedChoiceField(serializers.ChoiceField):

    @serializers.ChoiceField.choices.setter
    def choices(self, choices):
        self.grouped_choices = fields.to_choices_dict(choices)
        self._choices = fields.flatten_choices_dict(self.grouped_choices)
        # in py2 use `iteritems` or `six.iteritems`
        self.choice_strings_to_values = {v: k for k, v in self._choices.items()}

The @property override is "ugly" but my goal is always to change as little of the core as possible (to maximize forward compatibility).

P.S. if you want to allow_blank, there's a bug in DRF. The simplest workaround is to add the following to MappedChoiceField:

def validate_empty_values(self, data):
    if data == '':
        if self.allow_blank:
            return (True, None)
    # for py2 make the super() explicit
    return super().validate_empty_values(data)

P.P.S. If you have a bunch of choice fields that all need to be mapped this, way take advantage of the feature noted by @lechup and add the following to your ModelSerializer (not its Meta):

serializer_choice_field = MappedChoiceField