Python

Django Ninja Schemas and Many To Many

How to return an attribute from a ManytoMany relationship

Table of Contents
  1. The Code
  2. Addressing the problem
  3. Other alternatives
    1. Using the HabitCategorySchema
  4. Using a one-liner(ish)

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.

python
1class 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.

python
1def test_get_habit(habit_with_updates_and_interactions, django_client):
2 habit_id = habit_with_updates_and_interactions.id
3 response = django_client.get(reverse("api-v1:habit", kwargs={"pk": habit_id}))
4
5 assert response.status_code == 200
6 habit = response.json()
7 assert habit.title == "Think about my parents"

Finally, let's have a look at the view and the Schema:

python
1class HabitSchema(ModelSchema):
2 user: UserSchema
3 category: List[str]
4 total_updates: int
5 total_thumbs_up: int
6 total_comments: int
7
8 class Config:
9 model = Habit
10 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:

python
1pydantic.error_wrappers.ValidationError: 1 validation error for NinjaResponseSchema
2response -> category -> 0
3 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.

python
1# Updated Schema without category
2class HabitSchema(ModelSchema):
3 user: UserSchema
4 total_updates: int
5 total_thumbs_up: int
6 total_comments: int
7
8 class Config:
9 model = Habit
10 model_fields = [
11 "id",
12 "created_at",
13 "user",
14 "title",
15 "post",
16 "visibility",
17 "category",
18 ]
19
20# Response from the API
21{
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': 2
34}

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.

python
1class HabitSchema(ModelSchema):
2 user: UserSchema
3 category: List[str]
4 total_updates: int
5 total_thumbs_up: int
6 total_comments: int
7
8 class Config:
9 model = Habit
10 model_fields = [
11 "id",
12 "created_at",
13 "user",
14 "title",
15 "post",
16 "visibility",
17 "category",
18 ]
19
20 @staticmethod
21 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:

python
1{
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': 2
16}

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:

python
1class HabitSchema(ModelSchema):
2 created_at: datetime.datetime
3 user: UserSchema
4 title: str
5 post: str
6 category: List[HabitCategorySchema]
7 visibility: str
8 total_updates: int
9 total_thumbs_up: int
10 total_comments: int
11
12 class Config:
13 model = Habit
14 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': 2
40}

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

python
1HabitSchema = 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:

Webmentions

0 Like 0 Comment

You might also like these

Learn what additional permissions you need to add to your user to get django to run tests with a postgresql database.

Read More
Python

Fix django postgresql permissions denied on tests

Fix django postgresql permissions denied on tests

Twitch suggests that we should pass a `hub.secret` so we can compare sha-256 hashes and check the authenticity of the request. This is how I did it in Python.

Read More
Python

Handle Twitch hub.secret logic in python

Handle Twitch hub.secret logic in python

While working on adding tests to Pyscript I came across a use case where I had to check if an example image is always generated the same.

Read More
Python

How to compare two images using NumPy

How to compare two images using NumPy

Python dataclasses are a powerful feature that allow you to refactor and write cleaner code. This introduction will help you get started with Python dataclasses.

Read More
Python

Clean Code with Python Dataclasses

Clean Code with Python Dataclasses