3

I need to implement a RESTful API that pays attention to the inheritance of models with Django's REST Framwork. I think this is a neat approach for structuring the API. The underlying idea is to only differ the object classes by the set of attributes presented. But how can this be done with Django? Lets stick to a simple exemplary scenario:

  • Class Animal has attribute age.
  • Class Bird extends Animal by an attribute wing, e.g. its size.
  • Class Dog extends Animal by an attribute tail, e.g. its length.

Exemplary requirements:

  • It would be sufficient if /animals/ listed all animals with their general attributes, i.e. their age.
  • Assuming that the animal with ID=1 was a bird, /animals/1 should give something like:

{
    'age': '3',
    'class': 'Bird',
    'wing': 'big'
}
  • Assuming that the animal with ID=2 was a dog, /animals/2 should give something like:

{
    'age': '8',
    'class': 'Dog',
    'tail': 'long'
}

I had no luck implementing that Django's REST framework, mainly because I haven't succeeded in adding/removing class-specific fields. Especially I'm wondering, how the create operation is to be implemented in such scenario?

Community
  • 1
  • 1
theV0ID
  • 4,172
  • 9
  • 35
  • 56

1 Answers1

4

tl;dr: It isn't as complicated as it looks. This solution proposes a fully reusable class that realizes the requested usecase with a minimum of code, as one can see from the exemplary usage below.


After some fiddling, I've come up with following solution, that, I believe, is quite satisfying. We will need one helper function and two classes for this, there are no further dependencies.

An extension to theHyperlinkedModelSerializer

Suppose that a query returned an object from Animal class, that actually is a Bird. Than get_actual would resolve that Animal to an object form Bird class:

def get_actual(obj):
    """Expands `obj` to the actual object type.
    """
    for name in dir(obj):
        try:
            attr = getattr(obj, name)
            if isinstance(attr, obj.__class__):
                return attr
        except:
            pass
    return obj

The ModelField defines a field that names the model that underlies a serializer:

class ModelField(serializers.ChoiceField):
    """Defines field that names the model that underlies a serializer.
    """

    def __init__(self, *args, **kwargs):
        super(ModelField, self).__init__(*args, allow_null=True, **kwargs)

    def get_attribute(self, obj):
        return get_actual(obj)

    def to_representation(self, obj):
        return obj.__class__.__name__

The HyperlinkedModelHierarchySerializer does the magic:

class HyperlinkedModelHierarchySerializer(serializers.HyperlinkedModelSerializer):
    """Extends the `HyperlinkedModelSerializer` to properly handle class hierearchies.

    For an hypothetical model `BaseModel`, serializers from this
    class are capable of also handling those models that are derived
    from `BaseModel`.

    The `Meta` class must whitelist the derived `models` to be
    allowed. It also must declare the `model_dependent_fields`
    attribute and those fields must also be added to its `fields`
    attribute, for example:

        wing = serializers.CharField(allow_null=True)
        tail = serializers.CharField(allow_null=True)

        class Meta:
            model = Animal
            models = (Bird, Dog)
            model_dependent_fields = ('wing', 'tail')
            fields = ('model', 'id', 'name') + model_dependent_fields
            read_only_fields = ('id',)

    The `model` field is defined by this class.
    """
    model = ModelField(choices=[])

    def __init__(self, *args, **kwargs):
        """Instantiates and filters fields.

        Keeps all fields if this serializer is processing a CREATE
        request. Retains only those fields that are independent of
        the particular model implementation otherwise.
        """
        super(HyperlinkedModelHierarchySerializer, self).__init__(*args, **kwargs)
        # complete the meta data
        self.Meta.models_by_name = {model.__name__: model for model in self.Meta.models}
        self.Meta.model_names = self.Meta.models_by_name.keys()
        # update valid model choices,
        # mark the model as writable if this is a CREATE request
        self.fields['model'] = ModelField(choices=self.Meta.model_names, read_only=bool(self.instance))
        def remove_missing_fields(obj):
            # drop those fields model-dependent fields that `obj` misses
            unused_field_keys = set()
            for field_key in self.Meta.model_dependent_fields:
                if not hasattr(obj, field_key):
                    unused_field_keys |= {field_key}
            for unused_field_key in unused_field_keys:
                self.fields.pop(unused_field_key)
        if not self.instance is None:
            # processing an UPDATE, LIST, RETRIEVE or DELETE request
            if not isinstance(self.instance, QuerySet):
                # this is an UPDATE, RETRIEVE or DELETE request,
                # retain only those fields that are present on the processed instance
                self.instance = get_actual(self.instance)
                remove_missing_fields(self.instance)
            else:
                # this is a LIST request, retain only those fields
                # that are independent of the particular model implementation
                for field_key in self.Meta.model_dependent_fields:
                    self.fields.pop(field_key)

    def validate_model(self, value):
        """Validates the `model` field.
        """
        if self.instance is None:
            # validate for CREATE
            if value not in self.Meta.model_names:
                raise serializers.ValidationError('Must be one of: ' + (', '.join(self.Meta.model_names)))
            else:
                return value
        else:
            # model cannot be changed
            return get_actual(self.instance).__class__.__name__

    def create(self, validated_data):
        """Creates instance w.r.t. the value of the `model` field.
        """
        model = self.Meta.models_by_name[validated_data.pop('model')]
        for field_key in self.Meta.model_dependent_fields:
            if not field_key in model._meta.get_all_field_names():
                validated_data.pop(field_key)
                self.fields.pop(field_key)
        return model.objects.create(**validated_data)

Exemplary Usage

And this is how we can use it. In serializers.py:

class AnimalSerializer(HyperlinkedModelHierarchySerializer):

    wing = serializers.CharField(allow_null=True)
    tail = serializers.CharField(allow_null=True)

    class Meta:
        model = Animal
        models = (Bird, Dog)
        model_dependent_fields = ('wing', 'tail')
        fields = ('model', 'id', 'name') + model_dependent_fields
        read_only_fields = ('id',)

And in views.py:

class AnimalViewSet(viewsets.ModelViewSet):
    queryset = Animal.objects.all()
    serializer_class = AnimalSerializer
theV0ID
  • 4,172
  • 9
  • 35
  • 56
  • Keep in mind that if you use class inheritance with models like that there will be multiple tables and an implicit join for any query. You'd better use [abstract base classes](https://docs.djangoproject.com/en/1.8/topics/db/models/#abstract-base-classes). – dukebody Sep 11 '15 at 11:28
  • 1
    @dukebody: Right, but with an abstract base class `Animal` I couldn't do a query like `Animal.objects.all()` and also I couldn't make an union of `Dog.objects.all()` and `Bird.objects.all()`, which would make dealing with `Animal` objects quite unhandy. – theV0ID Sep 11 '15 at 12:12
  • Right! You might want to consider using a separate "extra" JSON field in the Animal model for specific animal attributes, or even something like [django-rest-framework-mongoengine] (https://github.com/umutbozkurt/django-rest-framework-mongoengine) if your animals are unrelated to other relational fields. – dukebody Sep 11 '15 at 12:16
  • @dukebody: Could you please explain in a little more detail what you mean by "extra" JSON field in the `Animal` model? – theV0ID Sep 11 '15 at 12:56
  • See https://www.vividcortex.com/blog/2015/06/02/json-support-postgres-mysql-mongodb-sql-server/. There are json field implementations for Django: https://www.djangopackages.com/grids/g/json-fields/ – dukebody Sep 11 '15 at 19:49
  • Django rest has changed, and sadly this no longer appears to work out of the box. It looks like "super(HyperlinkedModelHierarchySerializer, self)" tries to validate that the given fields are actually part of the model we initialized it with. – Alex Davies Dec 19 '16 at 00:08