From 31a13c7ccc3313c51895605c1da9fe331d085c9a Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Fri, 10 Jul 2026 23:02:17 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=B0=20Loc?= =?UTF-8?q?aleManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- net/xeaf/rack/enums/ui_language.py | 16 ++ net/xeaf/rack/managers/__init__.py | 11 + net/xeaf/rack/managers/locale_manager.py | 126 +++++++++ .../rack/tests/enums/ui_language_tests.py | 78 +++++- net/xeaf/rack/tests/managers/__init__.py | 12 + .../tests/managers/locale_manager_tests.py | 245 ++++++++++++++++++ 6 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 net/xeaf/rack/managers/__init__.py create mode 100644 net/xeaf/rack/managers/locale_manager.py create mode 100644 net/xeaf/rack/tests/managers/__init__.py create mode 100644 net/xeaf/rack/tests/managers/locale_manager_tests.py diff --git a/net/xeaf/rack/enums/ui_language.py b/net/xeaf/rack/enums/ui_language.py index e63216a..ba2423a 100644 --- a/net/xeaf/rack/enums/ui_language.py +++ b/net/xeaf/rack/enums/ui_language.py @@ -21,3 +21,19 @@ class UILanguage(CoreEnum): RUSSIAN = 'ru' """ Русский """ + + @classmethod + def from_string(cls, value: str) -> UILanguage: + """ + Создает значение из строкового представления + + :param value: Строковое значение + :return: Язык интерфейса пользователя + """ + + normalized = value.lower().split('-')[0] + for member in cls: + if member.value == normalized: + return member + + raise ValueError(f"Unsupported language: {value}") diff --git a/net/xeaf/rack/managers/__init__.py b/net/xeaf/rack/managers/__init__.py new file mode 100644 index 0000000..9c3c640 --- /dev/null +++ b/net/xeaf/rack/managers/__init__.py @@ -0,0 +1,11 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет классов менеджеров +""" + +from .locale_manager import LocaleManager diff --git a/net/xeaf/rack/managers/locale_manager.py b/net/xeaf/rack/managers/locale_manager.py new file mode 100644 index 0000000..76adddc --- /dev/null +++ b/net/xeaf/rack/managers/locale_manager.py @@ -0,0 +1,126 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса LocaleManager +""" + +from django.conf import settings +from django.utils import translation +from rest_framework.request import Request + +from net.xeaf.rack.enums import UILanguage + + +class LocaleManager: + """ + Реализует методы работы с локализацией + """ + + UI_LANGUAGE_PARAM = "x-ui-language" + """ Идентификатор параметра языка интерфейса """ + + UI_LANGUAGE_META = "HTTP_ACCEPT_LANGUAGE" + """ Идентификатор заголовка языка интерфейса """ + + @classmethod + def get_default_language(cls) -> UILanguage: + """ + Возвращает языка интерфейса пользователя по умолчанию + + :return: Язык интерфейса пользователя по умолчанию + """ + + return UILanguage.from_string(settings.LANGUAGE_CODE) + + @classmethod + def get_accepted_language(cls, language_code: str) -> UILanguage: + """ + Проверяет и возвращает допустимый код языка + + :param language_code: Код языка интерфейса пользователя + + :return: Допустимый код языка интерфейса пользователя + """ + + try: + return UILanguage.from_string(language_code) + except ValueError: + return cls.get_default_language() + + @classmethod + def get_request_language(cls, request: Request | None) -> UILanguage: + """ + Возвращает идентификатор языка из запроса + + :param request: Информация о запросе + + :return: Язык интерфейса пользователя из запроса + """ + + if not request: + return cls.get_default_language() + + if request.method == "GET": + language_code = request.query_params.get(cls.UI_LANGUAGE_PARAM, None) + if language_code: + return cls.get_accepted_language(language_code=language_code) + + try: + language_code = cls.process_accepted_language(request) + return cls.get_accepted_language(language_code=language_code) + except KeyError: + return cls.get_default_language() + + @classmethod + def set_request_language(cls, request: Request | None, ui_language: UILanguage) -> None: + """ + Задает язык интерфейса пользователя для запроса + + :param request: Информация о запросе + :param ui_language: Язык интерфейса пользователя + """ + + translation.activate(str(ui_language).lower()) + if request: + request.LANGUAGE_CODE = str(translation.get_language()).upper() + + @classmethod + def process_accepted_language(cls, request: Request) -> str: + """ + Обрабатывает значение META_ACCEPTED_LANGUAGE + + :param request: Информация о запросе + + :return: Код языка интерфейса пользователя из запроса + """ + + index = -1 + result = cls.get_default_language() + language = request.META[cls.UI_LANGUAGE_META] + + for ui_lang in UILanguage.values(): + lang = ui_lang.lower() + try: + idx = language.index(lang) + if index == -1 or idx < index: + index = idx + result = lang + except ValueError: + pass + + return result + + @classmethod + def adjust_request_language(cls, request: Request) -> None: + """ + Настраивает язык интерфейса пользователя + + :param request: Информация о запросе + """ + + language = cls.get_request_language(request) + cls.set_request_language(request, language) diff --git a/net/xeaf/rack/tests/enums/ui_language_tests.py b/net/xeaf/rack/tests/enums/ui_language_tests.py index 55a1512..7cf3975 100644 --- a/net/xeaf/rack/tests/enums/ui_language_tests.py +++ b/net/xeaf/rack/tests/enums/ui_language_tests.py @@ -13,7 +13,7 @@ from django.test import TestCase from net.xeaf.rack.enums import UILanguage -class UILanguageTest(TestCase): +class UILanguageTests(TestCase): """ Тесты для перечисления UILanguage """ @@ -34,3 +34,79 @@ class UILanguageTest(TestCase): for code in UILanguage.values(): self.assertEqual(len(code), 2) self.assertTrue(code.islower()) + + def test_from_string_returns_english_for_en(self): + """ + Проверяем, что from_string возвращает ENGLISH для 'en' + """ + + result = UILanguage.from_string('en') + self.assertEqual(result, UILanguage.ENGLISH) + + def test_from_string_returns_russian_for_ru(self): + """ + Проверяем, что from_string возвращает RUSSIAN для 'ru' + """ + + result = UILanguage.from_string('ru') + self.assertEqual(result, UILanguage.RUSSIAN) + + def test_from_string_handles_uppercase(self): + """ + Проверяем, что from_string корректно обрабатывает заглавные буквы + """ + + result = UILanguage.from_string('EN') + self.assertEqual(result, UILanguage.ENGLISH) + + result = UILanguage.from_string('RU') + self.assertEqual(result, UILanguage.RUSSIAN) + + def test_from_string_handles_regional_variants(self): + """ + Проверяем, что from_string корректно обрабатывает региональные варианты и лишние суффиксы + """ + + test_cases = [ + ('en-us', UILanguage.ENGLISH), + ('en-US', UILanguage.ENGLISH), + ('en-gb', UILanguage.ENGLISH), + ('en-GB', UILanguage.ENGLISH), + ('en-us-extra', UILanguage.ENGLISH), # лишний суффикс + ('en-us-extra-stuff', UILanguage.ENGLISH), # несколько суффиксов + ('ru-ru', UILanguage.RUSSIAN), + ('ru-RU', UILanguage.RUSSIAN), + ] + + for value, expected in test_cases: + with self.subTest(value=value): + result = UILanguage.from_string(value) + self.assertEqual(result, expected) + + def test_from_string_raises_value_error_for_unknown(self): + """ + Проверяем, что from_string выбрасывает ValueError для неизвестного языка + """ + + with self.assertRaises(ValueError): + UILanguage.from_string('fr') + + with self.assertRaises(ValueError): + UILanguage.from_string('de') + + with self.assertRaises(ValueError): + UILanguage.from_string('es') + + def test_from_string_raises_value_error_for_invalid_format(self): + """ + Проверяем, что from_string выбрасывает ValueError для некорректного формата + """ + + with self.assertRaises(ValueError): + UILanguage.from_string('123') + + with self.assertRaises(ValueError): + UILanguage.from_string('') + + with self.assertRaises(ValueError): + UILanguage.from_string('fr-FR') # 'fr' не поддерживается diff --git a/net/xeaf/rack/tests/managers/__init__.py b/net/xeaf/rack/tests/managers/__init__.py new file mode 100644 index 0000000..aad975b --- /dev/null +++ b/net/xeaf/rack/tests/managers/__init__.py @@ -0,0 +1,12 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет описания тестов для классов менеджеров +""" + +from .locale_manager_tests import LocaleManagerTests + diff --git a/net/xeaf/rack/tests/managers/locale_manager_tests.py b/net/xeaf/rack/tests/managers/locale_manager_tests.py new file mode 100644 index 0000000..9c636b9 --- /dev/null +++ b/net/xeaf/rack/tests/managers/locale_manager_tests.py @@ -0,0 +1,245 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса LocaleManagerTest +""" + +from django.conf import settings +from django.test import TestCase +from django.utils import translation +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from net.xeaf.rack.enums import UILanguage +from net.xeaf.rack.managers import LocaleManager + + +class LocaleManagerTests(TestCase): + """ + Тесты для менеджера локализации + """ + + def setUp(self): + """ + Подготовка данных для тестов + """ + self.factory = APIRequestFactory() + self.default_language = UILanguage.from_string(settings.LANGUAGE_CODE) + + def test_get_default_language_returns_settings_value(self): + """ + Проверяем, что метод возвращает язык из настроек + """ + result = LocaleManager.get_default_language() + + expected = UILanguage.from_string(settings.LANGUAGE_CODE) + self.assertEqual(result, expected) + + def test_get_accepted_language_returns_english_for_en(self): + """ + Проверяем, что для 'en' возвращается UILanguage.ENGLISH + """ + result = LocaleManager.get_accepted_language('en') + self.assertEqual(result, UILanguage.ENGLISH) + + def test_get_accepted_language_returns_russian_for_ru(self): + """ + Проверяем, что для 'ru' возвращается UILanguage.RUSSIAN + """ + result = LocaleManager.get_accepted_language('ru') + self.assertEqual(result, UILanguage.RUSSIAN) + + def test_get_accepted_language_handles_uppercase(self): + """ + Проверяем, что метод корректно обрабатывает заглавные буквы + """ + result = LocaleManager.get_accepted_language('EN') + self.assertEqual(result, UILanguage.ENGLISH) + + def test_get_accepted_language_returns_default_for_unknown(self): + """ + Проверяем, что для неизвестного кода возвращается язык по умолчанию + """ + result = LocaleManager.get_accepted_language('fr') + self.assertEqual(result, self.default_language) + + def test_get_accepted_language_returns_default_for_empty_string(self): + """ + Проверяем, что для пустой строки возвращается язык по умолчанию + """ + result = LocaleManager.get_accepted_language('') + self.assertEqual(result, self.default_language) + + def test_get_request_language_returns_default_for_none_request(self): + """ + Проверяем, что для None запроса возвращается язык по умолчанию + """ + result = LocaleManager.get_request_language(None) + self.assertEqual(result, self.default_language) + + def test_get_request_language_from_query_param_on_get_request(self): + """ + Проверяем, что для GET запроса язык берется из query параметра + """ + request = self.factory.get('/?x-ui-language=ru') + request.method = 'GET' + request = Request(request) + + result = LocaleManager.get_request_language(request) + self.assertEqual(result, UILanguage.RUSSIAN) + + def test_get_request_language_ignores_query_param_on_non_get(self): + """ + Проверяем, что для не-GET запроса query параметр игнорируется + """ + request = self.factory.post('/?x-ui-language=ru') + request.method = 'POST' + request.META['HTTP_ACCEPT_LANGUAGE'] = 'en-US,en;q=0.9' + request = Request(request) + + result = LocaleManager.get_request_language(request) + self.assertEqual(result, UILanguage.ENGLISH) + + def test_get_request_language_from_accept_header(self): + """ + Проверяем, что язык берется из заголовка Accept-Language + """ + request = self.factory.get('/') + request.method = 'GET' + request.META['HTTP_ACCEPT_LANGUAGE'] = 'ru-RU,ru;q=0.9,en;q=0.8' + request = Request(request) + + result = LocaleManager.get_request_language(request) + self.assertEqual(result, UILanguage.RUSSIAN) + + def test_get_request_language_returns_default_if_header_missing(self): + """ + Проверяем, что при отсутствии заголовка возвращается язык по умолчанию + """ + request = self.factory.get('/') + request.method = 'GET' + request = Request(request) + + result = LocaleManager.get_request_language(request) + self.assertEqual(result, self.default_language) + + def test_get_request_language_prefers_query_param_over_header(self): + """ + Проверяем, что query параметр имеет приоритет над заголовком + """ + request = self.factory.get('/?x-ui-language=en') + request.method = 'GET' + request.META['HTTP_ACCEPT_LANGUAGE'] = 'ru-RU,ru;q=0.9' + request = Request(request) + + result = LocaleManager.get_request_language(request) + self.assertEqual(result, UILanguage.ENGLISH) + + def test_set_request_language_activates_translation(self): + """ + Проверяем, что метод активирует перевод для указанного языка + """ + request = self.factory.get('/') + request = Request(request) + + LocaleManager.set_request_language(request, UILanguage.RUSSIAN) + + from django.utils import translation + self.assertEqual(translation.get_language(), 'ru') + + def test_set_request_language_sets_language_code_in_request(self): + """ + Проверяем, что метод устанавливает LANGUAGE_CODE в запросе + """ + request = self.factory.get('/') + request = Request(request) + + LocaleManager.set_request_language(request, UILanguage.ENGLISH) + + self.assertEqual(request.LANGUAGE_CODE, 'EN') + + def test_set_request_language_handles_none_request(self): + """ + Проверяем, что метод корректно обрабатывает None в запросе + """ + + LocaleManager.set_request_language(None, UILanguage.RUSSIAN) + self.assertEqual(translation.get_language(), 'ru') + + def test_process_accepted_language_returns_first_matching_language(self): + """ + Проверяем, что метод возвращает первый подходящий язык из заголовка + """ + request = self.factory.get('/') + request.META['HTTP_ACCEPT_LANGUAGE'] = 'fr-FR,fr;q=0.9,ru-RU,ru;q=0.8,en;q=0.7' + request = Request(request) + + result = LocaleManager.process_accepted_language(request) + self.assertEqual(result, 'ru') + + def test_process_accepted_language_returns_default_if_no_match(self): + """ + Проверяем, что если ни один язык не найден, возвращается язык по умолчанию + """ + request = self.factory.get('/') + request.META['HTTP_ACCEPT_LANGUAGE'] = 'fr-FR,fr;q=0.9,de;q=0.8' + request = Request(request) + + result = LocaleManager.process_accepted_language(request) + self.assertEqual(result, str(self.default_language).lower()) + + def test_process_accepted_language_raises_keyerror_if_header_missing(self): + """ + Проверяем, что при отсутствии заголовка выбрасывается KeyError + """ + request = self.factory.get('/') + request = Request(request) + + with self.assertRaises(KeyError): + LocaleManager.process_accepted_language(request) + + def test_adjust_request_language_sets_language_from_request(self): + """ + Проверяем, что метод настраивает язык на основе запроса + """ + request = self.factory.get('/?x-ui-language=ru') + request.method = 'GET' + request = Request(request) + + LocaleManager.adjust_request_language(request) + + from django.utils import translation + self.assertEqual(translation.get_language(), 'ru') + self.assertEqual(request.LANGUAGE_CODE, 'RU') + + def test_adjust_request_language_uses_default_if_no_language_found(self): + """ + Проверяем, что если язык не найден, используется язык по умолчанию + """ + request = self.factory.get('/') + request.method = 'GET' + request = Request(request) + + LocaleManager.adjust_request_language(request) + + from django.utils import translation + expected = str(self.default_language).lower() + self.assertEqual(translation.get_language(), expected) + + def test_adjust_request_language_handles_empty_request(self): + """ + Проверяем, что метод корректно обрабатывает пустой запрос + """ + request = self.factory.get('/') + request.method = 'GET' + request = Request(request) + + # Метод не должен выбрасывать исключений + try: + LocaleManager.adjust_request_language(request) + except Exception as e: + self.fail(f"adjust_request_language raised {e}")