Работа с Enum полями

This commit is contained in:
2026-07-30 20:12:39 +03:00
parent abd999d245
commit 50f6638416
26 changed files with 601 additions and 38 deletions

View File

@@ -0,0 +1,12 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет описания классов полей моделей данных
"""
from .enum_choice_field import EnumChoiceField
from .extensible_enum_choice_field import ExtensibleEnumChoiceField

View File

@@ -0,0 +1,146 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса EnumChoiceField
"""
from typing import Any
from typing import Sequence
from typing import Type
from django.core.exceptions import ValidationError
from django.db import models
from net.xeaf.rack.core.core_enum import CoreEnum
class EnumChoiceField(models.CharField):
"""
Поле выбора перечисления со строгой рантайм-валидацией типов
"""
MAX_DEFAULT_LENGTH = 64
""" Максимальный размер поля """
_enum_cls: Type[CoreEnum]
""" Класс перечисления """
def __init__(self, choices: Type[CoreEnum], *args, **kwargs):
"""
Инициализация
:param choices: Класс перечисления CoreEnum
:param args: Дополнительные аргументы
:param kwargs: Дополнительные параметры
"""
if not isinstance(choices, type) or not issubclass(choices, CoreEnum):
raise TypeError("The 'choices' parameter must be a subclass of CoreEnum.")
self._enum_cls = choices
kwargs.setdefault('max_length', self.MAX_DEFAULT_LENGTH)
kwargs['choices'] = choices.choices()
super().__init__(*args, **kwargs)
# noinspection unused-parameter
def from_db_value[E: CoreEnum](self, value: Any, expression: Any, connection: Any) -> E | str | None:
"""
Преобразует сырое значение из базы данных в объект CoreEnum
:param value: Входящее сырое значение
:param expression: Выражение для преобразования значения
:param connection: Соединение с базой данных
:return: Представление объекта CoreEnum или строка, или None
"""
return self._normalize_and_validate(value, action_context="read")
def to_python[E: CoreEnum](self, value: Any) -> E | str | None:
"""
Строго преобразует входящее значение в объект CoreEnum при очистке форм или сериализации
:param value: Входящее значение
:return: Объект CoreEnum или строка, или None
"""
return self._normalize_and_validate(value, action_context="clean")
def validate(self, value: Any, model_instance: Any) -> None:
"""
Валидация значения поля
"""
if value is None or value == "":
return super().validate(value, model_instance)
lookup_value = value.value if isinstance(value, self._enum_cls) else value
if lookup_value not in self._enum_cls.values():
raise ValidationError(
self.error_messages['invalid_choice'],
code='invalid_choice',
params={'value': value},
)
return super(models.CharField, self).validate(value, model_instance)
def get_prep_value(self, value: Any) -> str | None:
"""
Преобразует объект CoreEnum обратно в строку перед сохранением в БД
"""
# Стандартная проверка
value = super().get_prep_value(value)
# Проверяем строгость типов и ограничений через наш метод
resolved = self._normalize_and_validate(value, action_context="save")
if isinstance(resolved, self._enum_cls):
return resolved.value
return resolved
def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]:
"""
Возвращает параметры для сериализации поля
:return: Кортеж с параметрами для сериализации поля
"""
name, path, args, kwargs = super().deconstruct()
kwargs['choices'] = self._enum_cls
return name, path, args, kwargs
def _normalize_and_validate(self, value: Any, action_context: str) -> CoreEnum | str | None:
"""
Внутренний метод для проверки null/blank ограничений и приведения значения к CoreEnum.
Реализует паттерн Fail-Fast для предотвращения работы с некорректными данными.
:param value: Входящее сырое значение
:param action_context: Контекст выполнения для текста ошибки (e.g., 'read', 'save')
:return: Объект CoreEnum, пустая строка или None
:raises ValueError: Если значение нарушает правила null/blank или отсутствует в перечислении
"""
if value is None:
if not self.null:
raise ValueError(f"Cannot {action_context} null; the field '{self.name}' is not nullable.")
return None
if value == "":
if not self.blank:
raise ValueError(f"Cannot {action_context} blank string; the field '{self.name}' does not allow blanks.")
return ""
if isinstance(value, self._enum_cls):
return value
return self._enum_cls.from_string(str(value))

View File

@@ -0,0 +1,156 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ExtensibleEnumChoiceField
"""
from typing import Any
from typing import Sequence
from typing import Type
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from net.xeaf.rack.core.core_enum import CoreEnum
class ExtensibleEnumChoiceField(models.CharField):
"""
Конфигурируемое поле выбора перечисления, класс которого
полностью настраивается пользователем через settings.py.
"""
MAX_DEFAULT_LENGTH = 64
""" Максимальный размер поля по умолчанию """
_settings_key: str
""" Ключ в settings.py для получения замещающего класса перечисления """
_active_enum_cls: Type[CoreEnum]
""" Активный в текущем рантайме класс перечисления """
def __init__(self, choices: str, *args: Any, **kwargs: Any) -> None:
"""
Инициализация
"""
if not isinstance(choices, str):
raise TypeError(
f"The 'choices' parameter in {self.__class__.__name__} must be a string containing "
f"the settings key name, got {type(choices).__name__} instead."
)
self._settings_key = choices
user_enum = getattr(settings, self._settings_key, None)
if user_enum is None:
raise ValueError(
f"The required configuration setting '{self._settings_key}' "
f"is missing or not set in your settings.py!"
)
if not isinstance(user_enum, type) or not issubclass(user_enum, CoreEnum):
raise TypeError(
f"The '{self._settings_key}' setting in settings.py "
f"must be a subclass of CoreEnum to maintain strict typing."
)
self._active_enum_cls = user_enum
kwargs.setdefault('max_length', self.MAX_DEFAULT_LENGTH)
kwargs['choices'] = self._active_enum_cls.choices()
super().__init__(*args, **kwargs)
def _normalize_and_validate(self, value: Any, action_context: str) -> CoreEnum | str | None:
"""
Внутренний метод для проверки ограничений и приведения значения к активному классу CoreEnum.
"""
if value is None:
if not self.null:
raise ValueError(f"Cannot {action_context} null; the field '{self.name}' is not nullable.")
return None
if value == "":
if not self.blank:
raise ValueError(f"Cannot {action_context} blank string; the field '{self.name}' does not allow blanks.")
return ""
if isinstance(value, CoreEnum):
if isinstance(value, self._active_enum_cls):
return value
str_val = value.value
else:
str_val = str(value)
try:
return self._active_enum_cls.from_string(str_val)
except ValueError:
raise ValueError(
f"Cannot {action_context} value '{value}'; it cannot be resolved by active "
f"enum class '{self._active_enum_cls.__name__}' configured via '{self._settings_key}'."
)
# noinspection unused-parameter
def from_db_value[E: CoreEnum](self, value: Any, expression: Any, connection: Any) -> E | str | None:
"""
Преобразует сырое значение из базы данных в объект замещенного перечисления
"""
return self._normalize_and_validate(value, action_context="read")
def to_python[E: CoreEnum](self, value: Any) -> E | str | None:
"""
Строго преобразует входящее значение в объект замещенного перечисления
"""
return self._normalize_and_validate(value, action_context="clean")
def validate(self, value: Any, model_instance: Any) -> None:
"""
Валидация значения поля по замещенному списку
"""
if value is None or value == "":
return super().validate(value, model_instance)
lookup_value = value.value if isinstance(value, CoreEnum) else value
if lookup_value not in self._active_enum_cls.values():
raise ValidationError(
self.error_messages['invalid_choice'],
code='invalid_choice',
params={'value': value},
)
return super().validate(value, model_instance)
def get_prep_value(self, value: Any) -> str | None:
"""
Преобразует объект CoreEnum обратно в строку перед сохранением в БД
"""
value = super().get_prep_value(value)
resolved = self._normalize_and_validate(value, action_context="save")
if isinstance(resolved, self._active_enum_cls):
return resolved.value
return resolved
def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]:
"""
Возвращает параметры для сериализации поля
:return: Кортеж с параметрами для сериализации поля
"""
name, path, args, kwargs = super().deconstruct()
kwargs['choices'] = self._settings_key
return name, path, args, kwargs