Работа с 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

@@ -10,3 +10,4 @@
from .account_model import AccountModel
from .session_model import SessionModel
from .workspace_model import WorkspaceModel

View File

@@ -12,12 +12,13 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.translation import gettext_lazy as _
from net.xeaf.rack.enums import WorkspaceRole
from net.xeaf.rack.models.mixins import CreatedAtMixin
from net.xeaf.rack.models.mixins import DeletedAtMixin
from net.xeaf.rack.models.mixins import DescriptionMixin
from net.xeaf.rack.models.mixins import UpdatedAtMixin
from net.xeaf.rack.models.mixins import UuidIdMixin
from .workspace_model import WorkspaceModel
class AccountModel(AbstractUser, UuidIdMixin, CreatedAtMixin, UpdatedAtMixin, DeletedAtMixin, DescriptionMixin, ):
"""
@@ -73,6 +74,25 @@ class AccountModel(AbstractUser, UuidIdMixin, CreatedAtMixin, UpdatedAtMixin, De
)
""" Дата и время последнего входа """
workspace = models.ForeignKey(
WorkspaceModel,
on_delete=models.CASCADE,
null=False,
blank=False,
verbose_name=_("rack.models.workspace"),
)
""" Рабочее пространство пользователя """
role = models.CharField(
choices=WorkspaceRole.choices,
default=WorkspaceRole.OWNER,
null=False,
max_length=12,
blank=False,
verbose_name=_("rack.models.account.role"),
)
""" Роль пользователя """
extra = models.JSONField(
null=False,
default=dict,

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

View File

@@ -55,6 +55,7 @@ class JournalModel(CoreModel, CreatedAtMixin):
"""
Параметры конфигурации класса
"""
# Индексы для производительности
indexes = [
models.Index(fields=["account"]),

View File

@@ -10,3 +10,4 @@
from .account_test_data import AccountTestData
from .session_test_data import SessionTestData
from .workspace_test_data import WorkspaceTestData

View File

@@ -10,8 +10,10 @@
import uuid
from net.xeaf.rack.enums import WorkspaceRole
from net.xeaf.rack.models import AccountModel
from net.xeaf.rack.utils import DateUtils
from .workspace_test_data import WorkspaceTestData
class AccountTestData:
@@ -56,6 +58,8 @@ class AccountTestData:
account.full_name = "Иван И. Иванов"
account.email = "i.ivanov@xeaf.net"
account.set_password(cls.DEFAULT_PASSWORD)
account.workspace_id = WorkspaceTestData.IVANOV_WORKSPACE_ID
account.role = WorkspaceRole.OWNER
account.joined_at = DateUtils.now()
account.save()
@@ -72,6 +76,8 @@ class AccountTestData:
account.full_name = "Петр П. Петров"
account.email = "p.petrov@xeaf.net"
account.set_password(cls.DEFAULT_PASSWORD)
account.workspace_id = WorkspaceTestData.PETROV_WORKSPACE_ID
account.role = WorkspaceRole.OWNER
account.joined_at = DateUtils.now()
account.save()

View File

@@ -0,0 +1,64 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса WorkspaceTestData
"""
import uuid
from net.xeaf.rack.models import WorkspaceModel
class WorkspaceTestData:
"""
Инициализирует модели данных тестовых Рабочих пространств
"""
IVANOV_WORKSPACE_ID = uuid.UUID("377cbf24-6658-4b51-a7b2-7ca1ca0f331d")
""" Уникальный идентификатор пространства пользователя Иван Иванов """
IVANOV_WORKSPACE = 'i-ivanov'
""" Имя пространства пользователя Иван Иванов """
PETROV_WORKSPACE_ID = uuid.UUID("22711511-a3c2-4891-88c3-68c14272f949")
""" Уникальный идентификатор пространства пользователя Петр Петров """
PETROV_WORKSPACE = 'p-petrov'
""" Имя пространства пользователя Петр Петров """
@classmethod
def init_workspaces(cls):
"""
Инициализирует модели данных тестовых Рабочих пространств
"""
cls.init_ivanov_workspace()
cls.init_petrov_workspace()
@classmethod
def init_ivanov_workspace(cls):
"""
Инициализирует модель данных тестового пространства пользователя Иван Иванов
"""
workspace = WorkspaceModel()
workspace.id = cls.IVANOV_WORKSPACE_ID
workspace.slug = cls.IVANOV_WORKSPACE
workspace.description = "Пространство пользователя Иван Иванов"
workspace.save()
@classmethod
def init_petrov_workspace(cls):
"""
Инициализирует модель данных тестового пространства пользователя Петр Петров
"""
workspace = WorkspaceModel()
workspace.id = cls.PETROV_WORKSPACE_ID
workspace.slug = cls.PETROV_WORKSPACE
workspace.description = "Пространство пользователя Петр Петров"
workspace.save()

View File

@@ -0,0 +1,47 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса Session
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from net.xeaf.rack.core import CorePersistentModel
from net.xeaf.rack.enums import WorkspaceKind
from net.xeaf.rack.models.mixins import DescriptionMixin
from net.xeaf.rack.models.mixins import SlugMixin
class WorkspaceModel(CorePersistentModel, SlugMixin, DescriptionMixin):
"""
Модель рабочего пространства
"""
kind = models.CharField(
max_length=16,
choices=WorkspaceKind.choices,
default=WorkspaceKind.PRIVATE,
null=False,
blank=False,
verbose_name=_("rack.models.workspace_model.kind")
)
""" Вид пространства """
class Meta:
"""
Параметры конфигурации класса
"""
# Индексы для производительности
indexes = [
models.Index(fields=["slug"]),
]
db_table = "rack_workspaces"
verbose_name = _("rack.models.workspace")
verbose_name_plural = _("rack.models.workspaces")