Работа с Enum полями
This commit is contained in:
@@ -10,3 +10,4 @@
|
||||
|
||||
from .account_model import AccountModel
|
||||
from .session_model import SessionModel
|
||||
from .workspace_model import WorkspaceModel
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
net/xeaf/rack/models/fields/__init__.py
Normal file
12
net/xeaf/rack/models/fields/__init__.py
Normal 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
|
||||
146
net/xeaf/rack/models/fields/enum_choice_field.py
Normal file
146
net/xeaf/rack/models/fields/enum_choice_field.py
Normal 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))
|
||||
156
net/xeaf/rack/models/fields/extensible_enum_choice_field.py
Normal file
156
net/xeaf/rack/models/fields/extensible_enum_choice_field.py
Normal 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
|
||||
@@ -55,6 +55,7 @@ class JournalModel(CoreModel, CreatedAtMixin):
|
||||
"""
|
||||
Параметры конфигурации класса
|
||||
"""
|
||||
|
||||
# Индексы для производительности
|
||||
indexes = [
|
||||
models.Index(fields=["account"]),
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
|
||||
from .account_test_data import AccountTestData
|
||||
from .session_test_data import SessionTestData
|
||||
from .workspace_test_data import WorkspaceTestData
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
64
net/xeaf/rack/models/user_story/workspace_test_data.py
Normal file
64
net/xeaf/rack/models/user_story/workspace_test_data.py
Normal 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()
|
||||
47
net/xeaf/rack/models/workspace_model.py
Normal file
47
net/xeaf/rack/models/workspace_model.py
Normal 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")
|
||||
Reference in New Issue
Block a user