Search code examples
djangodjango-rest-frameworkgeodjango

Validation error with DRF PointField - "Enter a valid location"


In one of my APIs, I am able to get the DRF extra field, PointField working. In one of the other API where I am using PointField in a nested serializer, it is giving me a validation error.

{
"booking_address": {
    "coordinates": [
        "Enter a valid location."
    ]
}

}

And the payload data is

{
    "booking_address": {
        "coordinates" :       {
        "latitude": 49.87,
         "longitude": 24.45
        },
        "address_text": "A123"
    }
}

My serializers are below: BookingSerializer

class BookingSerializer(FlexFieldsModelSerializer):
    booked_services = BookedServiceSerializer(many=True)
    booking_address = BookingAddressSerializer(required=False)

   ------

    def validate_booking_address(self, address):
        if address.get("id"):
            address = BookingAddress.objects.get(id=address.get("id"))
        else:
            address["customer"] = self.context.get("request").user.id
            serializer = BookingAddressSerializer(data=address)
            if serializer.is_valid(): <---- error is coming from here
                address = serializer.save()
            else:
                raise ValidationError(serializer.errors)
        return address

My Address Serializer is defined as:

class BookingAddressSerializer(FlexFieldsModelSerializer):
    coordinates = geo_fields.PointField(srid=4326)
    customer = serializers.IntegerField(required=False)

And booking model is:

class BookingAddress(BaseTimeStampedModel):
    customer = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="booking_addresses", on_delete=models.CASCADE)
    coordinates = models.PointField()
    address_text = models.CharField(max_length=256)

Tried Debugging, been stuck here for a few hours now and not able to find the issue.

Any help will be appreciated.


Solution

  • Well, the problem is that to_internal_value() is called with a correct Pointfield, and because geofields.PointField only handles strings or dicts it fails.

    Here's the code I used to reproduce the problem (trimmed down, with imports):

    # models.py
    from __future__ import annotations
    import typing as t
    
    from django.contrib.gis.db import models
    from django.contrib.auth import get_user_model
    
    
    User = get_user_model()
    
    if t.TYPE_CHECKING:
        from django.contrib.auth.models import User
    
    
    class BaseTimeStampedModel(models.Model):
        created_at = models.DateTimeField(auto_now_add=True)
        last_modified = models.DateTimeField(auto_now=True)
    
        class Meta:
            abstract = True
    
    
    class BookingAddress(BaseTimeStampedModel):
        customer = models.ForeignKey(
            User, related_name="booking_addresses", on_delete=models.CASCADE,
        )
        coordinates = models.PointField(geography=True, srid=4326)
        address_text = models.CharField(max_length=256)
    
    
    class Booking(BaseTimeStampedModel):
        service = models.CharField(max_length=20)
        address = models.ForeignKey(
            BookingAddress, on_delete=models.CASCADE, related_name="booking"
        )
    
    # serializers.py
    import json
    
    from rest_framework import serializers
    from rest_framework.exceptions import ValidationError
    from drf_extra_fields import geo_fields
    
    from .models import BookingAddress, Booking
    from django.contrib.gis.geos import GEOSGeometry
    from django.contrib.gis.geos.error import GEOSException
    from django.contrib.gis.geos.point import Point
    
    EMPTY_VALUES = (None, "", [], (), {})
    
    
    class PointField(geo_fields.PointField):
        default_error_messages = {
            "invalid": "Enter a valid location.",
            "json": "Invalid json",
            "unknown": "Unknown cause",
            "wrong_type": "Expected string or dict",
        }
    
        def to_internal_value(self, value):
            if value in EMPTY_VALUES and not self.required:
                return None
    
            if isinstance(value, str):
                try:
                    value = value.replace("'", '"')
                    value = json.loads(value)
                except ValueError:
                    self.fail("json")
            print(type(value))
            if value and isinstance(value, dict):
                try:
                    latitude = value.get("latitude")
                    longitude = value.get("longitude")
                    return GEOSGeometry(
                        "POINT(%(longitude)s %(latitude)s)"
                        % {"longitude": longitude, "latitude": latitude},
                        srid=self.srid,
                    )
                except (GEOSException, ValueError) as e:
                    msg = e.args[0] if len(e.args) else "Unknown"
                    self.fail(f"unknown", msg=msg)
    
            if isinstance(value, Point):
                raise TypeError("Point received")
            self.fail(f"wrong_type")
    
    
    class BookingAddressSerializer(serializers.ModelSerializer):
        coordinates = PointField(srid=4326)
    
        class Meta:
            model = BookingAddress
            fields = ("coordinates", "customer_id")
    
    
    class BookingSerializer(serializers.ModelSerializer):
        booking_address = BookingAddressSerializer(required=False)
    
        def validate_booking_address(self, address):
            if address.get("id"):
                address = BookingAddress.objects.get("id")
            else:
                address["customer"] = self.context.get("request").user.id
                serializer = BookingAddressSerializer(data=address)
                if serializer.is_valid():
                    address = serializer.save()
                else:
                    raise ValidationError(serializer.errors)
    
            return address
    
        class Meta:
            model = Booking
            fields = ("service", "created_at", "last_modified", "booking_address")
            read_only_fields = ("created_at", "last_modified")
    
    
    # tests.py
    
    import json
    
    from django.contrib.auth import get_user_model
    from django.test import TestCase
    
    from .serializers import BookingSerializer
    
    
    User = get_user_model()
    
    class DummyRequest:
        user = None
    
    
    class BookingSerializerTest(TestCase):
        payload = json.dumps(
            {
                "service": "Dry cleaning",
                "booking_address": {
                    "coordinates": {"longitude": 24.45, "latitude": 49.87},
                    "address_text": "123 Main Street",
                },
            }
        )
    
        def test_serializer(self):
            user = User.objects.create_user(username="test_user", password="pass123456")
            request = DummyRequest()
            request.user = user
            serializer = BookingSerializer(
                data=json.loads(self.payload), context={"request": request}
            )
            self.assertTrue(serializer.is_valid(raise_exception=True))
    

    If you run the test you see in the output:

    <class 'dict'>
    <class 'django.contrib.gis.geos.point.Point'>
    

    And the 2nd time is where it fails, because it cannot handle a Point. This is caused by you overstepping your boundaries in validate_booking_address(). This causes to_internal_value to be called twice, the 2nd time with the result of the previous.

    You're trying to handle the entire convert > validate > save operation there and the method should only do the validate step. This means, check to see if the data matches the expected input.

    A nested field should validate itself and be able to create itself. If you need request.user to create a valid model you should override create() as explained by the documentation:

    If you're supporting writable nested representations you'll need to write .create() or .update() methods that handle saving multiple objects.