How to return an attribute from a ManytoMany relationship
Table of Contents
On a recent project, I am using Django Ninja instead of Django Rest Framework mainly because I wanted to have better control over typing and also because it's slightly easier to implement.
When writing the tests and the API for the project, it was clear that the ManyToMany relationship between the two models failed the validation.
The Code
These are the models. As you can see, we have a HabitCategory and a Habit. Each Habit can have a Category.
python1class HabitCategory(models.Model):2 title = models.CharField(max_length=255)3 icon = models.ImageField(null=True, blank=True)4
5
6class Habit(models.Model):7 created_at = models.DateTimeField(blank=False, auto_now_add=True)8 user = models.ForeignKey(User, on_delete=models.CASCADE)9 title = models.CharField(max_length=1000)10 post = models.TextField(blank=True)11 category = models.ManyToManyField(HabitCategory, blank=True)12 visibility = models.CharField(13 max_length=10,14 choices=HabitVisibilityChoices.choices,15 default=HabitVisibilityChoices.PUBLIC,16 )
The test itself is pretty basic. It makes a GET
request and tries to assert that the representation of the object is correct. It might be worth mentioning that I have created fixtures to create a test object, user and Django client.
python1def test_get_habit(habit_with_updates_and_interactions, django_client):2 habit_id = habit_with_updates_and_interactions.id3 response = django_client.get(reverse("api-v1:habit", kwargs={"pk": habit_id}))4
5 assert response.status_code == 2006 habit = response.json()7 assert habit.title == "Think about my parents"
Finally, let's have a look at the view and the Schema:
python1class HabitSchema(ModelSchema):2 user: UserSchema3 category: List[str]4 total_updates: int5 total_thumbs_up: int6 total_comments: int7
8 class Config:9 model = Habit10 model_fields = [11 "id",12 "created_at",13 "user",14 "title",15 "post",16 "visibility",17 "category",18 ]19
20@router.get(url_name="habit", path="/habit/{pk}", response=HabitSchema)21def get_habit(request: HttpRequest, pk: int):22 return get_object_or_404(Habit, pk=pk, user=request.user)
I am using a UserSchema
and a HabitCategorySchema
, which should give me just the user.username
and the habit_category.title
. But when the tests run, the following validation error pops up:
python1pydantic.error_wrappers.ValidationError: 1 validation error for NinjaResponseSchema2response -> category -> 03 field required (type=value_error.missing)
Okay, so it seems that the category doesn't have a title. This isn't surprising since the category has a ManyToMany relationship.
Addressing the problem
We can remove the category: HabitCategorySchema
mention in the Schema, and the tests will pass, but the category isn't what we expect.
python1# Updated Schema without category2class HabitSchema(ModelSchema):3 user: UserSchema4 total_updates: int5 total_thumbs_up: int6 total_comments: int7
8 class Config:9 model = Habit10 model_fields = [11 "id",12 "created_at",13 "user",14 "title",15 "post",16 "visibility",17 "category",18 ]19
20# Response from the API21{22 'id': 5,23 'created_at': '2022-08-16T09:11:48.041358+00:00',24 'user': {25 'username': 'batman'26 },27 'title': 'Think about my parents',28 'post': 'They were awesome',29 'visibility': 'private',30 'category': [14],31 'total_updates': 2,32 'total_thumbs_up': 3,33 'total_comments': 234}
As you can see, category
is now available, but it's returning a list and the HabitCategory id. But what we want is the title from this specific category.
The trick is to write a static method to fetch the category title.
python1class HabitSchema(ModelSchema):2 user: UserSchema3 category: List[str]4 total_updates: int5 total_thumbs_up: int6 total_comments: int7
8 class Config:9 model = Habit10 model_fields = [11 "id",12 "created_at",13 "user",14 "title",15 "post",16 "visibility",17 "category",18 ]19
20 @staticmethod21 def resolve_category(obj):22 return [category.title for category in obj.category.all()]
This will iterate over all the object categories and return the title for us. Note that we also need to add category: List[str]
to the Schema. When we run the tests, the API will now return:
python1{2 'id': 5,3 'created_at': '2022-08-16T09:23:19.361886+00:00',4 'user': {5 'username': 'batman'6 },7 'title': 'Think about my parents',8 'post': 'They were awesome',9 'visibility': 'private',10 'category': [11 'Family'12 ],13 'total_updates': 2,14 'total_thumbs_up': 3,15 'total_comments': 216}
Other alternatives
This article wouldn't be completed if I didn't show other ways to achieve a similar result. But you could use either of these alternatives and get good results. In my case, I wanted the category
to be a list of strings.
Using the HabitCategorySchema
We could pass the HabitCategorySchema to the Habit Schema, and it would work. For example:
python1class HabitSchema(ModelSchema):2 created_at: datetime.datetime3 user: UserSchema4 title: str5 post: str6 category: List[HabitCategorySchema]7 visibility: str8 total_updates: int9 total_thumbs_up: int10 total_comments: int11
12 class Config:13 model = Habit14 model_fields = [15 "id",16 "created_at",17 "user",18 "title",19 "post",20 "visibility",21 "category",22 ]23
24# The API returns:25{26 'id': 5,27 'created_at': '2022-08-16T09:28:25.872980+00:00',28 'user': {29 'username': 'batman'30 },31 'title': 'Think about my parents',32 'post': 'They were awesome',33 'visibility': 'private',34 'category': [35 {'title': 'Family'}36 ],37 'total_updates': 2,38 'total_thumbs_up': 3,39 'total_comments': 240}
The response is a bit more complex, but maybe that's okay for your use case.
Using a one-liner(ish)
Depending on your model, you may be able to use the create_schema
function to make Django Ninja create the Schema for us. Under the hood, the ModelSchema
class uses this function to generate the Schema, and we can also remove a lot of our code and use the
python1HabitSchema = create_schema(Habit, depth=1)
I couldn't make create_schema
work for this particular case and decided not to spend more time on this approach.
I hope this helps!
References: