I am creating this API and in determined point of my tests (contract creation endpoint) I am receiving an invalid error.
The error says that I am not passing some required attribute to the API when creating a contract, but I am. The weirdest thing is that when I tried to create from the Web browser by hand, the issue wasn't raised and the contract was created
I am puting here a lot of codes just for replication purposes, but the code that is really important to see it is the ContractSerializer
and the test_contract_creation
function
Here is my code:
models.py
from django.core.exceptions import ValidationError
from django.db import models
from django.core.validators import (
MaxValueValidator,
MinValueValidator,
RegexValidator,
)
from core.validators import GreaterThanValidator, luhn_validator
class Planet(models.Model):
name = models.CharField(
max_length=50, unique=True, null=False, blank=False
)
def __str__(self) -> str:
return self.name
class Route(models.Model):
origin_planet = models.ForeignKey(
Planet,
on_delete=models.CASCADE,
related_name="origin",
)
destination_planet = models.ForeignKey(
Planet,
on_delete=models.CASCADE,
related_name="destination",
)
fuel_cost = models.IntegerField(validators=[GreaterThanValidator(0)])
class Meta:
unique_together = (("origin_planet", "destination_planet"),)
def clean(self) -> None:
# super().full_clean()
if self.origin_planet == self.destination_planet:
raise ValidationError(
"Origin and destination planets must be different."
)
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def __str__(self) -> str:
return f"{self.origin_planet} - {self.destination_planet}"
class Ship(models.Model):
ship_model = models.CharField(max_length=50, null=False, unique=True)
fuel_capacity = models.IntegerField(validators=[GreaterThanValidator(0)])
weight_capacity = models.IntegerField(validators=[GreaterThanValidator(0)])
def __str__(self) -> str:
return self.ship_model
class Pilot(models.Model):
name = models.CharField(max_length=50, null=False)
age = models.IntegerField(
validators=[MinValueValidator(18), MaxValueValidator(60)]
)
certification = models.CharField(
max_length=7,
validators=[
RegexValidator(
r"^[0-9]{7}$", "Only digit characters and length 7"
),
luhn_validator,
],
null=False,
blank=False,
unique=True,
)
credits = models.PositiveIntegerField(default=0, editable=False)
location_planet = models.ForeignKey(Planet, on_delete=models.CASCADE)
ships = models.ManyToManyField(Ship, through="Ownership")
def __str__(self) -> str:
return f"{self.name}:{self.certification}"
class Ownership(models.Model):
"""Third table for Pilot and Ship relation"""
pilot = models.ForeignKey(Pilot, on_delete=models.CASCADE)
ship = models.ForeignKey(Ship, on_delete=models.CASCADE)
fuel_level = fuel_level = models.PositiveIntegerField(
default=100, validators=[MaxValueValidator(100)]
)
def __str__(self) -> str:
return f"{self.pilot} -> {self.ship} fuel_level:{self.fuel_level}"
class Resource(models.Model):
name = models.CharField(max_length=50, unique=True)
def save(self, *args, **kwargs):
self.name = self.name.lower()
super().save(*args, **kwargs)
def __str__(self) -> str:
return self.name
class Contract(models.Model):
description = models.CharField(max_length=50, null=False)
route = models.ForeignKey(Route, on_delete=models.CASCADE)
value = models.IntegerField(validators=[GreaterThanValidator(0)])
completed = models.BooleanField(default=False)
resources = models.ManyToManyField(Resource, through="Cargo")
def cargo_weight(self) -> int:
return sum([cargo.weight for cargo in self.cargo_set.all()])
def __str__(self) -> str:
return f"{self.route} contract - R${self.value}"
class Cargo(models.Model):
resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
contract = models.ForeignKey(Contract, on_delete=models.CASCADE)
weight = models.IntegerField(
validators=[GreaterThanValidator(0)], null=False
)
view.py
from django.forms.models import model_to_dict
from django.db.utils import IntegrityError
from rest_framework import viewsets
from rest_framework.reverse import reverse
from rest_framework.exceptions import ValidationError
from space_travel import serializers
from core import models
class ResourceViewSet(viewsets.ModelViewSet):
queryset = models.Resource.objects.all()
serializer_class = serializers.ResourceSerializer
class ShipViewSet(viewsets.ModelViewSet):
queryset = models.Ship.objects.all()
serializer_class = serializers.ShipSerializer
class PlanetViewSet(viewsets.ModelViewSet):
queryset = models.Planet.objects.all()
serializer_class = serializers.PlanetSerializer
class PilotViewSet(viewsets.ModelViewSet):
queryset = models.Pilot.objects.all()
serializer_class = serializers.PilotSerializer
class RouteViewSet(viewsets.ModelViewSet):
queryset = models.Route.objects.all()
def get_serializer_class(self):
if self.action in ["retrieve", "list"]:
# This serializer will show a hyperlinked
# representation for origin/destination fields
return serializers.RouteReadSerializer
# This serializer will use th planet names for writing
# instead of hyperlinked representations
return serializers.RouteWriteSerializer
class ContractViewSet(viewsets.ModelViewSet):
queryset = models.Contract.objects.all()
serializer_class = serializers.ContractSerializer
serializers.py
from typing import Dict
from django.urls.base import reverse
from rest_framework import serializers
from rest_framework.reverse import reverse
from core import models
class ResourceSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Resource
fields = "__all__"
class PlanetSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Planet
fields = "__all__"
class RouteWriteSerializer(serializers.ModelSerializer):
"""This serializers will be used only for write operations
like update, create"""
origin_planet = serializers.SlugRelatedField(
slug_field="name",
queryset=models.Planet.objects.all(),
)
destination_planet = serializers.SlugRelatedField(
slug_field="name",
queryset=models.Planet.objects.all(),
)
class Meta:
model = models.Route
fields = "__all__"
class RouteReadSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Route
fields = "__all__"
class PilotSerializer(serializers.HyperlinkedModelSerializer):
location_planet = serializers.SlugRelatedField(
many=False, queryset=models.Planet.objects.all(), slug_field="name"
)
ships = serializers.SlugRelatedField(
queryset=models.Ship.objects.all(), many=True, slug_field="ship_model"
)
class Meta:
model = models.Pilot
fields = "__all__"
def to_representation(self, instance: models.Pilot) -> Dict:
"""This method is responsible for represent the ship list
in a payload mode (should access the ownership table):
{
url: {schema}://{domain}/{path to the ship},
fuel_level: {fuel level} (comes from the ownership table)
}
"""
representation = super().to_representation(instance)
representation["ships"] = []
for ownership in instance.ownership_set.all():
ship_url = reverse( # Get the ship url
"space_travel:ship-detail",
args=[ownership.ship.pk],
request=self._context["request"],
)
representation["ships"].append(
{"url": ship_url, "fuel_level": ownership.fuel_level}
)
return representation
class ShipSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Ship
fields = "__all__"
class CargoSerializer(serializers.ModelSerializer):
resource = serializers.SlugRelatedField(
queryset=models.Resource.objects.all(),
slug_field="name",
read_only=False,
)
class Meta:
model = models.Cargo
fields = ["resource", "weight"]
class ContractSerializer(serializers.HyperlinkedModelSerializer):
origin_planet = serializers.SlugRelatedField(
slug_field="name",
queryset=models.Planet.objects.all(),
many=False,
write_only=True,
)
destination_planet = serializers.SlugRelatedField(
slug_field="name",
queryset=models.Planet.objects.all(),
many=False,
write_only=True,
)
route = RouteReadSerializer(many=False, read_only=True)
cargo_set = CargoSerializer(many=True)
class Meta:
model = models.Contract
fields = [
"description",
"value",
"route",
"origin_planet",
"destination_planet",
"cargo_set",
]
def to_representation(self, instance: models.Contract) -> Dict:
representation = super().to_representation(instance)
representation["cargos"] = representation.pop("cargo_set")
representation["total_weight"] = instance.cargo_weight()
return representation
def create(self, validated_data: Dict) -> models.Contract:
route = models.Route.objects.get(
origin_planet=validated_data.pop("origin_planet"),
destination_planet=validated_data.pop("destination_planet"),
)
cargos = validated_data.pop("cargo_set")
contract = models.Contract.objects.create(
**validated_data, route=route
)
for cargo in cargos:
contract.resources.add(
cargo.pop("resource"), through_defaults=cargo
)
return contract
test_contract_endpoint.py
import random
from django.test import TestCase
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from core.models import Cargo, Contract, Planet, Resource, Route
def link_list(endpoint: str, domain: bool = False):
link_path = reverse(f"space_travel:{endpoint}-list")
return link_path if not domain else f"http://testserver{link_path}"
def link_details(endpoint: str, _id: int, domain: bool = False):
link_path = reverse(f"space_travel:{endpoint}-detail", args=[_id])
return link_path if not domain else f"http://testserver{link_path}"
class TestContractEndpoint(TestCase):
def setUp(self):
self.client = APIClient()
self.endpoints = {
"contract": "contract",
"route": "route",
"planet": "planet",
}
self.sample_pl1 = Planet.objects.get_or_create(name="Andvari")[0]
self.sample_pl2 = Planet.objects.get_or_create(name="Demeter")[0]
self.sample_resources = [
Resource.objects.create(name=f"resource {n}") for n in range(4)
]
self.sample_route = Route.objects.create(
origin_planet=self.sample_pl1,
destination_planet=self.sample_pl2,
fuel_cost=100,
)
self.sample_contract = Contract.objects.create(
route=self.sample_route,
value=1000,
description="Contract to deliver water and food to Demeter",
)
self.sample_contract.resources.set(
self.sample_resources, through_defaults={"weight": 500}
)
def test_contract_creation(self):
"""Test if the contract creation are automatically setting the route
passing the origin and destination planets names and connecting the
resources to the contract through the Cargo model."""
resources = [
Resource.objects.get_or_create(name="minerals")[0],
random.choice(self.sample_resources),
]
payload = {
"description": "Contract to deliver minerals to Saturn",
"origin_planet": self.sample_pl1.name,
"destination_planet": self.sample_pl2.name,
"value": 1000,
"cargo_set": [
{"resource": resources[0].name, "weight": 545},
{"resource": resources[1].name, "weight": 876},
],
}
res = self.client.post(link_list(self.endpoints["contract"]), payload)
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
contract = Contract.objects.filter(
description=payload["description"],
route=self.sample_route,
value=payload["value"],
resources__in=[resource.id for resource in resources],
).first()
self.assertTrue(contract)
filtered_cargos = Cargo.objects.filter(contract=contract)
self.assertEqual(filtered_cargos.count(), 2)
for idx, cargo in enumerate(filtered_cargos):
self.assertEqual(cargo.weight, payload["cargo"][idx]["weight"])
Now this is the validation error I am receiving:
{'cargo_set': [ErrorDetail(string='This field is required.', code='required')]}
Here is a print of the contract that I created by hand in web browser and the payload passed to it was the same from the unit test:
After some research in the source code I noticed that my cargo_set
are being instatiated as a ListSerializer
and the problem is when the field.get_value()
is called inside the .to_internal_data()
. I actually don't know why, but the regex inside the parse_html_list()
isn't being matched. This was the only thing that I noticed that could be the problem
I would be glad for any help. Thanks!
CodePudding user response:
This seems to be the same Problem I had Nested deserialisation fails with "This field is required"
Make sure to use the APITest which sends the request JSON encoded.
To fix it I changed my testcase to use the APIClient provided by DRF:
from rest_framework.test import APIClient
client = APIClient()
In my settings.py I added the following to the configuration:
REST_FRAMEWORK = {
...
'TEST_REQUEST_DEFAULT_FORMAT': 'json'
}