Переводы полей моделей Django + Vue

6d758fb736550f2ff2d6c89373152d18.png

Всем привет! Это вторая статья цикла о разработке приложений в нашей компании. В первой статье я рассказал про общую архитектуру некоторых наших проектов. Сегодня же опишу наши варианты решения часто встречающихся задач в рамках Django + Vue приложения.

Переводы в моделях

Скорее всего, все уже умеют работать с интернационализацией в Django приложениях: достаточно использовать методы gettext, gettext_lazy из стандартного модуля Django, собрать все переводы в .po файл, используя стандартную команду управления, написать переводы и скомпилировать .mo файл. Но что делать, когда бизнес требует переводить некоторые сущности, которые хранятся в БД?

Ради примера представим, что у нас есть небольшой проект для рисования. Фигуры, которые можно рисовать на холсте, ограничены набором объектов Shape (фигура):

class Shape(models.Model):
  name = models.CharField(max_length=15)

Объекты этой модели добавляются по мере внедрения функционала рисования, и их количество не безгранично. Если вы уже ознакомились с нашей архитектурой то знаете, что такие данные мы отправляем на клиентское приложение по WS сразу после подключения пользователя. Таким образом клиент получает список вида:

[
  {
    id: 1,
    name: "Круг",
  },
  {
    id: 2,
    name: "Квадрат",
  },
  ...
]

Всё было хорошо, пока не появилась необходимость добавить механизм изменения языка на клиенте. Vue-i18n работает хорошо, но он не может переводить динамические данные, т.е. данные полученные из БД.

Теоретически может

В теории мы можем добавить в модель поле slug для хранения уникального значения с названием фигуры (circle, square, …), по которому будем искать строку перевода с помощью vue-i18n: $t(main.shapes.${shape.slug}). Но у такого подхода есть минус — при добавлении каждой новой фигуры в БД необходимо обновлять словари перевода vue-i18n. Но зачем тогда хранить такие данные в БД, а не в обычном перечислении?

Для решения это задачи написано множество библиотек, одной из которых мы и воспользуемся —django-modeltranslation. Инструкции по подключению и настройке данного пакета в этой статье не будет, т.к. у него есть документация и, скорее всего, такие статьи уже есть на хабре.

Итак, регистрация модели как переводимой:

from modeltranslation.translator import TranslationOptions, register


@register(Shape)
class ShapeTranslationOptions(TranslationOptions):
  fields = ("name", )

После применения миграции, у таблицы, с которой связана модель, появятся дополнительные поля: name_ для каждого языка, указанного в настройках. После этого можно добавить названия для наших фигур на всех необходимых языках, перейдя в панель администратора Django.

На этом всё?

Теперь, клиентское приложение получит данные на том языке, на котором отправит запрос на получение этих данных. Таким образом, если у пользователя в браузере включен русский язык, он получит данные на русском языке. Если язык английский (например), он получит данные на своем языке (если наше приложение поддерживает английский):

[
  {
    id: 1,
    name: "Circle",
  },
  {
    id: 2,
    name: "Square",
  },
  ...
]

Этого достаточно? Пользователи довольны? По большей части да. Недоволен только наш полиглот-тестировщик, который меняет язык каждые 3 секунды для тестирования интерфейса (спасибо ему за это =)).

Клиентское приложение не хранит названия фигур на всех языках, и для его отображения на нужном языке требуется обновить страницу или выполнить запрос на получение объектов заново. Ещё хуже дела обстоят в ситуации, когда кто-то добавляет или изменяет фигуру, после чего в сокет приходит сообщение:

 {
   event: "shape_added", 
   data: { 
     id: 3, 
     name: "Треугольник",
   }
 }

Отправку сообщения инициировал пользователь, у которого активирован русский язык, поэтому и сообщение содержит информацию на русском языке.

Будем исправлять

Сериализатор, используемый для отправки списка объектов в клиентское приложение, выглядит следующим образом:

class ReadShapeSerializer(serializers.ModelSerializer):
    class Meta:
        model = Shape
        fields = ("id", "name")

В момент сериализации, методы DRF запрашивают у каждого объекта атрибут name, а пакет modeltranslation сам понимает текущий активированный язык и отдает значение в зависимости от языка: либо name_ru, либо name_en. Мы написали небольшую примесь для сериализатора:

from modeltranslation.translator import translator
from modeltranslation.utils import get_translation_fields


class TranslationFieldsMixin:
    def get_fields(self):
        opts = self.Meta
        orig_fields = opts.fields
        new_fields = []
        trans_opts = translator.get_options_for_model(opts.model)

        for field_name in orig_fields:
            if field_name in trans_opts.fields:
                new_fields.extend(get_translation_fields(field_name))
            else:
                new_fields.append(field_name)
        self.Meta.fields = tuple(new_fields)
        return super().get_fields()

Он меняетMeta.fields для сериализатора модели, убирает оттуда переводимые поля и добавляет поля с переводами:

class ReadShapeSerializer(TranslationFieldsMixin, serializers.ModelSerializer):
    class Meta:
        model = Shape
        fields = ("id", "name_ru", "name_en")

Таким образом, просто добавив примесь в сериализатор, каждый объект будет содержать все поля с переводами:

// Получение объектов по API
[
  {
    id: 1,
    name_ru: "Круг",
    name_en: "Circle",
  },
  {
    id: 2,
    name_ru: "Квадрат",
    name_en: "Square",
  },
  ...
]

// Сокет событие
{
  event: "shape_added", 
  data: { 
    id: 3, 
    name_ru: "Треугольник",
    name_en: "Triangle",
  }
}

Клиентское приложение

Сервер отправляет всё, а frontend-разработчики пусть страдают и пишут в каждом компоненте что-то вроде:

{{ shape[`name${i18n.currentLanguage}`] }}

подумали мы и не стали так делать. Если вы смотрели демо-проект, то видели, что в клиентском приложении мы не работаем с объектами в сыром виде, для каждого объекта мы создаем экземпляр его класса и работаем с этим экземпляром:

class Shape {
  constructor(data) {
    this.id = data.id
    this.name = data.name
  }
}

Для реализации возможности работы с объектами этого класса как и прежде, мы написали ещё один класс, который отвечает за получения переводимого атрибута на нужном языке:

export default class TranslatableModel {
  /**
   * Конструктор класса получает названия переводимых полей, 
   * добавляет геттер получения оригинального поля "названиеПоля" 
   * и метод установки этого поля.
   * **/
  constructor(data) {
    const translationFields = this.getTranslationFields()

    for (const field of translationFields) {
      this.defineProperty(field)
    }
  }

  /**
   * Создает на экземпляре класса геттер и сеттер для работы с полем fieldName.
   * При обращении instance.field пытается найти instance.fieldRu 
   * (если ru - это текущий язык). Если значение поля для текущего 
   * языка не указано, перебирает запасные локализации (i18n.fallbackLocale),
   * для каждой из них пытается получить значение instance[field] 
   * пока не найдет заполненное поле.
   *
   * Метод сеттер принимает все данные об объекте и берет 
   * из них значения обрабатываемого поля со всеми вариантами перевода.
   * **/
  defineProperty(fieldName) {
    Object.defineProperty(this, fieldName, {
      get() {
        for (const language of this.getUserLanguages()) {
          const transFieldName = `${fieldName}${language}`
          const value = this[transFieldName]
          if (!isEmpty(value)) {
            return value
          }
        }
        return undefined
      },
      set(data) {
        const languages = this.getLanguages()
        for (const language of languages) {
          const transFieldName = `${fieldName}${language}`
          this[transFieldName] = data[transFieldName]
        }
      },
    })
  }
  /**
   * Пустой метод, сделанный только для того, чтобы не было вопросов, 
   * почему в поле записывается весь объект data:
   *
   * class SomeClass extends TranslatableModel {
   *   constructor(data) {
   *     super(data)
   *     this.name = data
   *   }
   *
   *   getTranslationFields() {
   *     return ['name']
   *   }
   * }
   *
   * На самом деле в этом примере при вызове this.name = data 
   * вызывается метод setter для поля name, который достает
   * из объекта data поля nameRu, nameEn, ... и устанавливает их как 
   * атрибуты объекта.
   *
   * **/
  getField(data) {
    return data
  }

  /**
   * Метод получения списка полей, подлежащих переводу.
   * **/
  getTranslationFields() {
    return []
  }
  
  /**
   * Возвращает список поддерживаемых языков.
   * **/
  getLanguages() {
    const languages = Object.keys(i18n.global.messages)
    return languages.map(capitalize)
  }
  
  /**
   * Метод генератор, по очереди возвращает следующий язык, который 
   * необходимо искать у экземпляра при доступе к атрибуту.
   * **/
  *getUserLanguages() {
    yield capitalize(i18n.global.locale)
    const fallbackLanguages = this.getFallbackLanguages()
    for (const language of fallbackLanguages) {
      yield language
    }
  }
  
  /**
   * Метод возвращает список запасных локализаций. Т.к. i18n.fallbackLocale 
   * может быть разного типа (строка, список строк, объект), приводит 
   * всё к списку.
   * **/
  getFallbackLanguages() {
    const userLocale = capitalize(i18n.global.locale)
    const fallbackConfig = i18n.global.fallbackLocale
    let locales

    if (isArray(fallbackConfig)) {
      locales = fallbackConfig
    } else if (isObject(fallbackConfig)) {
      locales = Object.keys(fallbackConfig)
    } else {
      locales = [fallbackConfig]
    }

    return locales.filter((lang) => lang !== userLocale).map(capitalize)
  }
}

Осталось унаследовать наш класс фигуры от этого базового класса, чтобы всё заработало:

class Shape extends TranslatableModel {
  constructor(data) {
    super(data)
    this.id = data.id
    this.name = this.getField(data)
  }

  getTranslationFields() {
    return ['name']
  }
}

После всех преобразований, как на сервере, так и на клиенте, мы можем писать Vue компоненты как обычно, не обращая внимания на текущий активированный язык:

{{ shape.name }}

Мы подготовили небольшой демонстрационный проект на codesandbox, где можно посмотреть, как это работает, и поиграть с получившимся результатом.

Спасибо за внимание!

© Habrahabr.ru