djangodjango-ninja

How to include a field's display text (i.e., "get_foo_display") in a ModelSchema in django ninja?


Problem

Let's say I have a django model with an IntegerField with a defined set of choices and then a django ninja schema and endpoint to update this model. How can I access the display text for the IntegerField (i.e., get_foo_display)?

In other words, my current schema returns a 1, 2, or 3 for the "rating" field. How can I get it to return the display text as well?

Example Code

models.py

from django.db import models
from django.utils.translation import gettext_lazy as _

class PracticeSession(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=False,related_name="practice_sessions", db_index=True,
    )

    class RATING_CHOICES(models.IntegerChoices):
        UNHELP = 1, _('unhelpful')
        NEITHER = 2, _('neither helpful nor unhelpful')
        HELP = 3, _('helpful')

    rating = models.IntegerField(choices=RATING_CHOICES.choices)
    is_practice_done = models.BooleanField(default=False)

api.py

from ninja import NinjaAPI, Schema, ModelSchema
from ninja.security import django_auth
from practice.models import PracticeSession

api = NinjaAPI(csrf=True, auth=django_auth)

class UpdatePracticeSessionSchema(ModelSchema):
    class Meta:
        model = PracticeSession
        fields = ['is_practice_done', 'rating']

@api.put(
    "member/{member_id}/practice_session/{sesh_id}/update/",
    response={200: UpdatePracticeSessionSchema, 404: NotFoundSchema}
)
def update_practice_sesh(request, member_id: int, sesh_id: int, data: UpdatePracticeSessionSchema):
    try:
        practice_sesh = PracticeSession.objects.get(pk=sesh_id)
        
        practice_sesh.is_practice_done = data.is_practice_done
        practice_sesh.rating = data.rating

        practice_sesh.save()
        return practice_sesh

    except Exception as e:
        print(f"Error in update_practice_sesh: {str(e)}")
        return 404, {'message': f'Error: {str(e)}'}

Things I've tried

I tried adding rating_choices = PracticeSession.rating.field.choices to my UpdatePracticeSessionSchema before class: Meta, but this triggered a pydantic error (see below), and, besides, this extra field in my Schema would have only gotten me a step closer to creating some sort of a mapping object (which would involve me writing extra javascript to extract the display text for whatever integer value the schema returns), but I'd much rather just have my Schema return the display text for the exact integer value it's returning as well.

Pydantic Error

pydantic.errors.PydanticUserError: A non-annotated attribute was detected: rating_choices = [(1, 'unhelpful'), (2, 'neither helpful nor unhelpful'), (3, 'helpful')]. All model fields require a type annotation; if rating_choices is not meant to be a field, you may be able to resolve this error by annotating it as a ClassVar or updating model_config['ignored_types'].


Solution

  • Since you want to override the behavior of how the field is serialized, you will want to use custom serialization logic. Strangely enough, this should be done using field validators instead of field serializers. More on why in this related StackOverflow question.

    First define a simple Schema for your rating field:

    from ninja import Schema
    
    class RatingSchema(Schema):
        # You can choose the actual names you want for these fields
        value: id
        label: str
    

    And then update your model schema to use this field schema and "validate" it with field_validator:

    from ninja import ModelSchema
    from pydantic import field_validator
    
    from practice.models import PracticeSession
    
    class UpdatePracticeSessionSchema(ModelSchema):
        # rating should use your custom schema
        rating: RatingSchema
    
        class Meta:
            model = PracticeSession
            fields = ['is_practice_done', 'rating'] 
        
        # mode='before' ensures that this runs before other validators
        # Note that the value is an int since this is what the PracticeSession.rating returns
        @field_validator('rating', mode='before')
        @classmethod
        def validate_rating(cls, value: int) -> RatingSchema:
            # Turn the value from the model into a RATING_CHOICES
            # This will throw a ValueError if it's an invalid value
            # which works in our favor since Pydantic will catch this
            # and throw a ValidationError
            rating = PracticeSession.RATING_CHOICES(value)
            return RatingSchema(value=rating.value, label=rating.label)