djangodjango-modelsdjango-rest-framework

Django custom model queryset returns fields dynamically


I have a few models that are translatable.

The way we implemented it was: for each translatable model, there is a ModelTranslation model.
E.g. for the Car model exists a CarTranslation model with FK to Car.

The translation models also have as fields: the language, and any text/char fields of the original model.
E.g. if the Car model looks like this

class Car(models.Model):
  name = models.CharField()
  description = models.TextField()

the CarTranslation then looks like this:

class CarTranslation(models.Model):
  car = models.ForeignKey(to=Car)
  language = models.CharField(choices=LANGUAGE_CHOICES)
  name = models.CharField()
  description = models.TextField()

In views that return translatable data, we check what language the user has defined: if it's english (the default) we return Car as it is; if it's not english, we return the translated data in CarTranslation for the user language (if it exists, otherwise default to english).

We have an old implementation of this that I want to improve: right now we add the translations in the serializer context and then, in the serializer, we check if there are translations, otherwise return the default.

But I don't like this.

I think a much better solution would be using custom model managers/querysets.
I'm trying this:

from django.db.models.functions import Coalesce
from django.db.models import F, Subquery

class CarQuerySet(models.QuerySet):
  def with_translation(self, language):
    from cars.models import CarTranslation

    if language == "en":
      return self
    translation_query = CarTranslation.objects.filter(language=language, car=OuterRef("pk"))
    return self.annotate(
      name=Coalesce(Subquery(translation_query.values("name")[:1]), F("name")),
      description=Coalesce(Subquery(translation_query.values("description")[:1]), F("description")),
    )

And then use it in the views like this:

class CarView(ListAPIView):
  def get_queryset(self):
    user = self.request.user
    language = user.language
    return Car.objects.with_translation(language)

But it produces this error: ValueError: The annotation 'name' conflicts with a field on the model.

I've also tried annotating with a different name, then not selecting the conflicting columns and returning the annotated values instead:

from django.db.models.functions import Coalesce
from django.db.models import F, Subquery

class CarQuerySet(models.QuerySet):
  def with_translation(self, language):
    from cars.models import CarTranslation

    if language == "en":
      return self
    translation_query = CarTranslation.objects.filter(language=language, car==OuterRef("pk"))
    queryset = self.annotate(
      translated_name=Coalesce(Subquery(translation_query.values("name")[:1]), F("name")),
      translated_description=Coalesce(Subquery(translation_query.values("description")[:1]), F("description")),
    )

    return queryset.values(
      *[field.name for field in self.model._meta.fields if field.name not in ["name", "description"]]
      name=F("translated_name"),
      description=F("translated_description"),
    )

but it produces the same exact error.

Any idea how I can return either the default or the translated value from the custom queryset?


Solution

  • You can not work with the name of a field that already exists, this could also result in a lot of name clashes, and behavior that is not ambiguous for the runtime environment, but makes it very complicated for the programmer.

    from django.db.models import F, Subquery
    from django.db.models.functions import Coalesce
    
    
    class CarQuerySet(models.QuerySet):
        def with_translation(self, language):
            from cars.models import CarTranslation
    
            if language == 'en':
                return self.annotate(custom_name=F('name'), custom_description=F('description'))
            translation_query = CarTranslation.objects.filter(
                language=language, car_id=OuterRef('pk')
            )
            return self.annotate(
                custom_name=Coalesce(
                    Subquery(translation_query.values('name')[:1]), F('name')
                ),
                custom_description=Coalesce(
                    Subquery(translation_query.values('description')[:1]),
                    F('description'),
                ),
            )

    Then in the serializer, we can use the custom_name and custom_description:

    class CarSerializer(serializers.ModelSerializer):
        name = serializers.CharField(source='custom_name')
        description = serializers.CharField(source='custom_description')
    
        class Meta:
            model = Car
            fields = ['name', 'description']