53

I have a 'through' model governing a many to many relationship and i want to be able to return the 'through' model and the target model as flat data, as opposed to having the target model nested.

So using the standard example for a many to many with a through, say these are the models,

class Person(models.Model):
    first_name = models.CharField(max_length=128)
    last_name = models.CharField(max_length=128)
    favourite_food = models.CharField(max_length=128)

class Group(models.Model):
    name = models.CharField(max_length=128)
    members = models.ManyToManyField(Person, through='Membership')

class Membership(models.Model):
    person = models.ForeignKey(Person)
    group = models.ForeignKey(Group)
    date_joined = models.DateField()
    invite_reason = models.CharField(max_length=64)

So the serializers i have at the moment to return Membership items are,

class MembershipSerializer(serializers.HyperlinkedModelSerializer):
    person = PersonSerializer()

    class Meta:
        model = Membership
        fields = ('id', 'url', 'group', 'date_joined', 'invite_reason', 'person')

class PersonSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = Person
        fields = ('first_name', 'last_name', 'favourite_food')

So when i retrieve a Membership model using the MembershipSerializer, i get this json,

{
    'id':1,
    'url':'http://cheeselovers.com/api/member/1/'
    'group':'http://cheeselovers.com/api/group/1/'
    'date_joined': '2014-01-24T16:33:40.781Z',
    'invite_reason': 'loves cheese',
    'person':{
        'first_name':'Barry',
        'last_name':'CheeseLover',
        'favourite_food': 'cheese'
    }
}

but what i'd like returned is this,

{
    'id':1,
    'url':'http://cheeselovers.com/api/member/1/'
    'group':'http://cheeselovers.com/api/group/1/'
    'date_joined': '2014-01-24T16:33:40.781Z',
    'invite_reason': 'loves cheese',
    'first_name':'Barry',
    'last_name':'CheeseLover',
    'favourite_food': 'cheese'
}

Now i realise that i could simply accomplish this by changing the MembershipSerializer to this,

class MembershipSerializer(serializers.HyperlinkedModelSerializer):
    first_name = serializers.Field(source='person.first_name')
    last_name = serializers.Field(source='person.last_name')
    favourite_food = serializers.Field(source='person.favourite_food')

    class Meta:
        model = Membership
        fields = ('id', 'url', 'group', 'date_joined', 'invite_reason', 'first_name', 'last_name', 'favourite_food')

BUT, the target model i have has 10 properties and the intermediary 'through' model only has read only props, so i already have a functioning serializer for the target model, that's used during the creation of the intermediary model.

It feels more DRY to be able to reuse this, so that if anything on the target model changes, i only have to make changes to it's serializer, for those changes to be then be reflected in the data returned by the intermediary's serializer.

So is there a way i can get the data from the PersonSerializer and add it to the Membership data, so that it's flat instead of nested?

...hope that all makes sense.

6 Answers 6

64

Here's an approach based on James's answer but for a newer version of Django Rest Framework and support for reading and writing (update of the nested field only, it should be easy enough to add creation, see DRF's documentation for that.)

class ProfileSerializer(serializers.ModelSerializer):
    class Meta:
        model = Profile
        fields = ('phone', 'some', 'other', 'fields')


class UserDetailsSerializer(serializers.ModelSerializer):
    """User model with Profile. Handled as a single object, profile is flattened."""
    profile = ProfileSerializer()

    class Meta:
        model = User
        fields = ('username', 'email', 'profile')
        read_only_fields = ('email', )

    def to_representation(self, obj):
        """Move fields from profile to user representation."""
        representation = super().to_representation(obj)
        profile_representation = representation.pop('profile')
        for key in profile_representation:
            representation[key] = profile_representation[key]

        return representation

    def to_internal_value(self, data):
        """Move fields related to profile to their own profile dictionary."""
        profile_internal = {}
        for key in ProfileSerializer.Meta.fields:
            if key in data:
                profile_internal[key] = data.pop(key)

        internal = super().to_internal_value(data)
        internal['profile'] = profile_internal
        return internal

    def update(self, instance, validated_data):
        """Update user and profile. Assumes there is a profile for every user."""
        profile_data = validated_data.pop('profile')
        super().update(instance, validated_data)

        profile = instance.profile
        for attr, value in profile_data.items():
            setattr(profile, attr, value)
        profile.save()

        return instance
Sign up to request clarification or add additional context in comments.

8 Comments

This should be bumped to the accepted answer. Thanks!
Seriously, this is so nice and clean. Great solution, thank you!
One pitfall, if both objects serialize a field with the same name such as 'id', the flattened objects field will override the parent's.
Perfect, that is exactly what I needed using DRF 3.7. Thanks!
You could shorten the for loop and the intermediate variable in to_representation to representation.update(representation.pop('profile'))
|
20

James' answer is what I finally used. As I had several serializers using this method, I converted it to a mixin:

class FlattenMixin(object):
    """Flatens the specified related objects in this representation"""
    def to_representation(self, obj):
        assert hasattr(self.Meta, 'flatten'), (
            'Class {serializer_class} missing "Meta.flatten" attribute'.format(
                serializer_class=self.__class__.__name__
            )
        )
        # Get the current object representation
        rep = super(FlattenMixin, self).to_representation(obj)
        # Iterate the specified related objects with their serializer
        for field, serializer_class in self.Meta.flatten:
            serializer = serializer_class(context = self.context)
            objrep = serializer.to_representation(getattr(obj, field))
            #Include their fields, prefixed, in the current   representation
            for key in objrep:
                rep[field + "__" + key] = objrep[key]
        return rep

This way, you can do something like:

class MembershipSerializer(FlattenMixin, serializers.HyperlinkedModelSerializer):
    class Meta:
        model = Membership
        fields = ('id', 'url', 'group', 'date_joined', 'invite_reason')
        flatten = [ ('person', PersonSerializer) ]

3 Comments

great, thanks for this. but the class should be: class FlattenMixin(object): in stead of class FlattenMixin:
for depopulate, double underscore would also be wise.... __ so rep[field + "__" + key] = objrep[key] (the same as django does in filter queries)
great, really helpful :)
14

I'm not convinced this is the simplest way, but the solution i came up with was to override the to_native method of the MembershipSerializer and then manually create and invoke the to_native method of the PersonSerializer and merge the two resulting dictionary's together

class MembershipSerializer(serializers.HyperlinkedModelSerializer):

    def to_native(self, obj):

        ret = super(MembershipSerializer, self).to_native(obj)
        p_serializer = PersonSerializer(obj.person, context=self.context)
        p_ret = p_serializer.to_native(obj.person)

        for key in p_ret:
            ret[key] = p_ret[key]

        return ret

    class Meta:
        model = Membership
        fields = ('id', 'url', 'group', 'date_joined', 'invite_reason', 'person')

The dictionary's are both a subclass of SortedDict. I'm not sure whether there's an explicit method to merge the two that preserves the order, so i've just used a loop instead.

2 Comments

This works well, but one thing stumped me. You need to replace 'obj.item' with the field name of the relation you're working with. So in the above example, it should read 'obj.person'.
Note: With newer versions of DRF this should now override to_representation, which has replaced to_native.
6

I didn't try it with HyperlinkedModelSerializer but with ModelSerializer you can make custom serializer class that supports flatten option.

class CustomModelSerializer(serializers.ModelSerializer):
    def __init__(self, *args, **kwargs):
        self.flatten = kwargs.pop('flatten', False)
        super(CustomModelSerializer, self).__init__(*args, **kwargs)

    def get_fields(self):
        fields = super(CustomModelSerializer, self).get_fields()
        for field_name, field in fields.items():
            if getattr(field, 'flatten', False):
                del fields[field_name]
                for nested_field_name, nested_field in field.fields.iteritems():
                    nested_field.source = (field_name + '.' +
                                           (nested_field.source or nested_field_name))
                    fields[nested_field_name] = nested_field
        return fields

Usage:

class MembershipSerializer(CustomModelSerializer):
    person = PersonSerializer(flatten=True)

    class Meta:
        model = Membership
        fields = ('person', ...)


class PersonSerializer(CustomModelSerializer):

    class Meta:
        model = Person
        fields = (...)

6 Comments

This is totally broken and makes no sense.
This code works perfectly in our code base. Obviously it is read-only. I can explain how it works if you need.
So we check serializer fields and if some of them are serializers with flatten option then add all its fields to the current serializer fields. We fix their source so it will contain full absolute path instead of just field name. Flatten serializers in other flatten serializers are also supported!
Would you mind pointing out why you believe this is broken, @ben?
@Raz - this works great; however, for python3, you need to update a few things: the compiler will through an error for changing a dictionary that you are iterating so I performed a deep copy of the fields dictionary and iterated over that. Also, iteritems needs to be changed to items.
|
1

Here's another solution to add the fields dynamically thanks to meta classes!

class FlattenNestedFields(SerializerMetaclass):
    def __new__(cls, name, bases, attrs):
        klass = super().__new__(cls, name, bases, attrs)
        for related_name, fields in attrs['related_fields']:
            klass.Meta.fields += fields
            for field in fields:
                field_rhs = ReadOnlyField(source=f'{related_name}.{field}')
                klass._declared_fields[field] = field_rhs
                setattr(klass, field, field_rhs)
        return klass

Now, when defining your serializer, all you need to do is this:

class MembershipSerializer(serializers.HyperlinkedModelSerializer, metaclass=FlattenNestedFields):
    related_fields = [
        ('person', ['first_name', 'last_name', 'favourite_food']),
    ]

    class Meta:
        model = Membership
        fields = ('id', 'url', 'group', 'date_joined', 'invite_reason',)

and the attributes (as well as the Meta inner class fields) are generated by the metaclass.

Comments

0

combining ekuusela's answer and this example from the DRF documentatation, you can also control which fields (from the nested object) you want to display.
Your serializer would look like this

class UserDetailsSerializer(serializers.ModelSerializer):
    """User model with Profile. Handled as a single object, profile is flattened."""

    profile = ProfileSerializer()

    def __init__(self, *args, **kwargs):
        self.allow_fields = kwargs.pop('fields', None)
        super(ProfileSerializer, self).__init__(*args, **kwargs)

    class Meta:
        model = User
        fields = ('username', 'email', 'profile')

    def to_representation(self, instance):
        representation = super().to_representation(instance)
        profile_representation = representation.pop('profile')
        representation.update(profile_representation)

        if self.allow_fields is not None:
            # Drop any fields that are not specified in the `fields` argument.
            allowed = set(self.allow_fields)
            existing = set(representation)
            for field_name in existing - allowed:
                representation.pop(field_name)
        return representation

And you would instantiate your Serializer as if it was only a singe Model

serializer = UserDetailsSerializer(user, fields=('username', 'email','profile_field1', 'profile_field2'))

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.