Using Django Model Validations in Django Rest Framework

While coding models in Django, we generally add validations for that model in the clean method. This helps us in making sure our model does not store invalid data.

Let's try to understand this through code:

# core/models.py
from django.core.exceptions import ValidationError
from django.db import models


def Appointment(models.Model):
    start_time = models.DateTimeField()
    end_time = models.DateTimeFIeld()

def clean(self):
    if self.start_time >= self.end_time:
        raise ValidationError('start_time must be less than end_time')

def save(self, *args, **kwargs):
    # django does not call full_clean by default during save
    self.full_clean()
    super(TestModel, self).save(*args, **kwargs)

Run the migrations to create this model in the database:

python manage.py makemigrations
python manage.py migrate

Now let's try to test our validation.

from core.models import Appointment
from datetime import datetime

st = datetime(2021, 3, 2, 14, 0)
et = datetime(2021, 3, 2, 13, 0)  # note that et is less than st

Appointment.objects.create(start_time=st, end_time=et)

Output:

ValidationError: {'__all__': ['start_time must be less than end_time']}

Perfect, so our validation is running. Now let's create a DRF view and see if this Validation is picked up by DRF or not.

First, let's add the serializer:

# core/serializers.py
class AppointmentSerializer(serializers.ModelSerializer):

    class Meta:
        model = Appointment
        fields = (
            'start_time',
            'end_time',
        )

Now let's add the view:

# core/views.py

class AppointmentViewSet(viewsets.ModelViewSet):
    serializer_class = AppointmentSerializer
    permission_classes = [permissions.AllowAny]

And then, let's add this view to the urls file

# core/urls.py

router = DefaultRouter()
router.register('appiontment', views.AppointmentViewSet, basename='appointment')
urlpatterns += router.urls

Now that we have all the pieces in place, we can test out the view. I'll be using the amazing httpie module to make an API request. If you don't have httpie installed, you can install it by typing pip install httpie or use curl or Postman.

http -h post localhost:8000/api/core/appointment/ start_time="2021-03-02T14:00:00Z" end_time="2021-03-02T13:00:00Z"

Note: -h is for printing headers only

Output:

HTTP/1.1 500 Internal Server Error
Content-Length: 143203
Content-Type: text/html
Referrer-Policy: same-origin
Vary: Origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

So our API is returning a 500 Internal Server Error! This is the problem that we're trying to resolve. The API should return a 400 Bad Request instead with the same message that we got through validation check: start_time must be less than end_time

If you see the server output, we'll see that we're indeed getting the same validation error, but DRF is not capturing that properly to raise a serializer.ValidationError on it's end but instead returning a 500 error.

django.core.exceptions.ValidationError: {'__all__': ['start_time must be less than end_time']}
HTTP POST /api/appointment/test_model/ 500 [0.06, 127.0.0.1:63059]
HTTP POST /api/appointment/test_model/ 500 [0.06, 127.0.0.1:63059]

So how do we capture this Django's ValidationError and raise a serializer.ValidationError through DRF? Various solutions have been discussed in this Github issue.

Solution 1: Add a validate method in serializer and add those validations again

class AppointmentSerializer(serializers.ModelSerializer):

    class Meta:
        model = Appointment
        fields = (
            'start_time',
            'end_time',
        )

    def validate(self, attrs):
        start_time = attrs.get('start_time')
        end_time = attrs.get('end_time')

        if start_time >= end_time:
            raise serializers.ValidationError('start_time should be less than end_time')

        return attrs

This will return the correct status code and response for the API request:

http post localhost:8000/api/core/appointment/ start_time="2021-03-02T14:00:00Z" end_time="2021-03-02T13:00:00Z"

Output:

HTTP/1.1 400 Bad Request
Allow: GET, POST, HEAD, OPTIONS
Content-Length: 64
Content-Type: application/json
Referrer-Policy: same-origin
Vary: Accept, Origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "non_field_errors": [
        "start_time should be less than end_time"
    ]
}

This is not an ideal way though as we'll have to repeat all the validations (in model's clean method as well as serializer's validate method) violating the DRY principle.

Solution 2: Use a Mixin to trap these Django Validation Errors and raise DRF Serializer Validation Errors instead.

class TrapDjangoValidationErrorCreateMixin(object):
    def perform_create(self, serializer):
        try:
            super(TrapDjangoValidationErrorCreateMixin, self).perform_create(serializer)
        except ValidationError as detail:
            # Note: You can also use detail.message
            error_message = {
                'non_field_errors': [detail]
            }
            raise serializers.ValidationError(error_message)


class TrapDjangoValidationErrorUpdateMixin(object):
    def perform_update(self, serializer):
        try:
            super(TrapDjangoValidationErrorUpdateMixin, self).perform_update(serializer)
        except ValidationError as detail:
            # Note: You can also use detail.message
            error_message = {
                'non_field_errors': [detail]
            }
            raise serializers.ValidationError(error_message)


class TrapDjangoValidationErrorMixin(TrapDjangoValidationErrorCreateMixin,
                                     TrapDjangoValidationErrorUpdateMixin):
    pass

Now you can use this mixin in the view as follows:

class AppointmentViewSet(TrapDjangoValidationErrorMixin, viewsets.ModelViewSet):
    serializer_class = AppointmentSerializer
    permission_classes = [permissions.AllowAny]

Let's see if this returns the correct status code and error message:

http post localhost:8000/api/core/appointment/ start_time="2021-03-02T14:00:00Z" end_time="2021-03-02T13:00:00Z"

Output:

HTTP/1.1 400 Bad Request
Allow: GET, POST, HEAD, OPTIONS
Content-Length: 79
Content-Type: application/json
Referrer-Policy: same-origin
Vary: Accept, Origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "non_field_errors": [
        "{'__all__': ['start_time must be less than end_time']}"
    ]
}

Perfect! Now we can just define all our Validations in one place (Django Model's clean method) and be sure that DRF will also pick them up and return a proper response.