Подготовка к переносу классов моделей

This commit is contained in:
2026-07-13 12:43:19 +03:00
parent c9a75b3370
commit c5e4c88588
8 changed files with 338 additions and 27 deletions

View File

@@ -9,3 +9,5 @@
""" """
from .ui_language import UILanguage from .ui_language import UILanguage
from .auth_reject_reson import AuthRejectReason

View File

@@ -0,0 +1,42 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса AuthRejectReason
"""
from django.utils.translation import gettext as _
from net.xeaf.rack.core import CoreEnum
class AuthRejectReason(CoreEnum):
"""
Перечисление причин отказа в авторизации
"""
INVALID_USERNAME_OR_PASSWORD = "invalid_username_or_password"
""" Некорректный логин или пароль """
ACCOUNT_IS_NOT_ACTIVE = "account_is_not_active"
""" Аккаунт не активен """
REGISTRATION_IS_NOT_COMPLETE = "registration_is_not_complete"
""" Регистрация не завершена """
@classmethod
def get_message(cls, reason: str) -> str:
"""
Возвращает текст сообщения по причине отказа в авторизации
"""
if reason == cls.INVALID_USERNAME_OR_PASSWORD:
return _("rack.errors.invalid_username_or_password")
elif reason == cls.ACCOUNT_IS_NOT_ACTIVE:
return _("rack.errors.account_is_not_active")
elif reason == cls.REGISTRATION_IS_NOT_COMPLETE:
return _("rack.errors.registration_is_not_complete")
return ""

View File

@@ -8,6 +8,8 @@
Пакет классов менеджеров Пакет классов менеджеров
""" """
from .account_manager import AccountManager
from .configuration_manager import ConfigurationManager from .configuration_manager import ConfigurationManager
from .crypto_manager import CryptoManager from .crypto_manager import CryptoManager
from .locale_manager import LocaleManager from .locale_manager import LocaleManager
from .session_manager import SessionManager

View File

@@ -8,10 +8,19 @@
Описание класса AccountManager Описание класса AccountManager
""" """
from net.xeaf.rack.models.account_model import AccountModel
class AccountManager: class AccountManager:
""" """
Реализует методы работы с Аккаунтами Реализует методы работы с Аккаунтами
""" """
pass @classmethod
def restore_account(cls, account: AccountModel) -> None:
"""
Восстанавливает удаленный Аккаунт
"""
if account.is_deleted():
account.undelete(save=True)

View File

@@ -8,15 +8,20 @@
Описание класса SessionManager Описание класса SessionManager
""" """
from typing import NoReturn
from typing import Type from typing import Type
from django.apps import apps from django.apps import apps
from django.contrib.auth import authenticate
from environ import ImproperlyConfigured from environ import ImproperlyConfigured
from net.xeaf.rack.enums import AuthRejectReason
from net.xeaf.rack.managers import ConfigurationManager from net.xeaf.rack.managers import ConfigurationManager
from net.xeaf.rack.managers import CryptoManager from net.xeaf.rack.managers import CryptoManager
from net.xeaf.rack.models import AccountModel from net.xeaf.rack.models import AccountModel
from net.xeaf.rack.models import SessionModel from net.xeaf.rack.models import SessionModel
from net.xeaf.rack.utils.exceptions import UnauthorizedException
from .account_manager import AccountManager
class SessionManager[T:SessionModel]: class SessionManager[T:SessionModel]:
@@ -27,21 +32,39 @@ class SessionManager[T:SessionModel]:
DEFAULT_SESSION_TTL = 3600 DEFAULT_SESSION_TTL = 3600
""" Время жизни сессии по умолчанию """ """ Время жизни сессии по умолчанию """
__model_class: Type[T] | None
""" Хранилище для класса сессии """
@classmethod @classmethod
def authenticate(cls, username: str, password: str) -> SessionModel: def authenticate(cls, username: str, password: str, device_id: str | None = None) -> T:
""" """
Авторизует новую сессию Авторизует новую сессию
:param username: Имя пользователя :param username: Имя пользователя
:param password: Пароль :param password: Пароль
:param device_id: Идентификатор устройства
:return: Объект данных сессии :return: Объект данных сессии
""" """
pass # noinspection PyTypeChecker
account: AccountModel | None = authenticate(username=username, password=password)
# Обрабатываем ошибки
if account is None:
cls._raise_auth_error(AuthRejectReason.INVALID_USERNAME_OR_PASSWORD)
elif not account.is_active:
cls._raise_auth_error(AuthRejectReason.ACCOUNT_IS_NOT_ACTIVE)
elif account.joined_at is None:
cls._raise_auth_error(AuthRejectReason.REGISTRATION_IS_NOT_COMPLETE)
# Создаем и возвращаем объект сессии
AccountManager.restore_account(account)
session = cls.open_session(account, account, device_id)
return session
@classmethod @classmethod
def find_by_token(cls, token: str) -> SessionModel | None: def find_by_token(cls, token: str) -> T | None:
""" """
Возвращает объект данных сессии по токену Возвращает объект данных сессии по токену
@@ -50,7 +73,8 @@ class SessionManager[T:SessionModel]:
:return: Объект данных сессии или None, если не найдена или просрочена :return: Объект данных сессии или None, если не найдена или просрочена
""" """
session = SessionModel.objects.filter(token__exact=token).first() model = cls._get_session_model_class()
session = model.objects.filter(token__exact=token).first()
if session is not None and not session.is_expired(): if session is not None and not session.is_expired():
return session return session
@@ -74,7 +98,17 @@ class SessionManager[T:SessionModel]:
return session return session
@classmethod @classmethod
def renew_session(cls, session: SessionModel) -> None: def close_session(cls, session: T) -> None:
"""
Закрывает сессию пользователя
:param session: Модель данных сессии
"""
session.expire(save=True)
@classmethod
def renew_session(cls, session: T) -> None:
""" """
Обновляет время существование сессии Обновляет время существование сессии
@@ -84,16 +118,6 @@ class SessionManager[T:SessionModel]:
ttl = ConfigurationManager.get_int("SESSION_TTL", cls.DEFAULT_SESSION_TTL) ttl = ConfigurationManager.get_int("SESSION_TTL", cls.DEFAULT_SESSION_TTL)
session.renew(ttl, save=True) session.renew(ttl, save=True)
@classmethod
def close_session(cls, session: SessionModel) -> None:
"""
Закрывает сессию пользователя
:param session: Модель данных сессии
"""
session.expire(save=True)
@classmethod @classmethod
def _create_session_model(cls, account: AccountModel, actual_account: AccountModel) -> T: def _create_session_model(cls, account: AccountModel, actual_account: AccountModel) -> T:
""" """
@@ -118,16 +142,31 @@ class SessionManager[T:SessionModel]:
:return: Класс модели данных сессии :return: Класс модели данных сессии
""" """
model_path = ConfigurationManager.get_string("AUTH_SESSION_MODEL", "net_xeaf_rack.AccountModel") if cls.__model_class is None:
model_path = ConfigurationManager.get_string("AUTH_SESSION_MODEL", "net_xeaf_rack.SessionModel")
app_label, model_name = model_path.split(".") app_label, model_name = model_path.split(".")
result = apps.get_model(app_label, model_name) model = apps.get_model(app_label, model_name)
# Нет класса # Нет класса
if not result: if not model:
raise ImproperlyConfigured("AUTH_SESSION_MODEL must be set in settings.py") raise ImproperlyConfigured("AUTH_SESSION_MODEL must be set in settings.py")
# Некорректный класс # Некорректный класс
if not issubclass(result, SessionModel): if not issubclass(model, SessionModel):
raise ImproperlyConfigured(f"AUTH_SESSION_MODEL must be a subclass of {SessionModel.__name__}") raise ImproperlyConfigured(f"AUTH_SESSION_MODEL must be a subclass of {SessionModel.__name__}")
return result cls.__model_class = model
# noinspection PyTypeChecker
return cls.__model_class
@classmethod
def _raise_auth_error(cls, reason: AuthRejectReason) -> NoReturn:
"""
Выбрасывает исключение с ошибкой аутентификации
:param reason: Причина ошибки
"""
detail = AuthRejectReason.get_message(reason)
raise UnauthorizedException(detail=detail, reason=reason)

View File

@@ -11,3 +11,4 @@
from .configuration_manager_tests import ConfigurationManagerTests from .configuration_manager_tests import ConfigurationManagerTests
from .crypto_manager_tests import CryptoManagerTests from .crypto_manager_tests import CryptoManagerTests
from .locale_manager_tests import LocaleManagerTests from .locale_manager_tests import LocaleManagerTests
# from .sesson_manager_tests import SessionManagerTests

View File

@@ -20,7 +20,7 @@ from net.xeaf.rack.managers import LocaleManager
class LocaleManagerTests(TestCase): class LocaleManagerTests(TestCase):
""" """
Тесты для менеджера локализации Тесты для класса LocaleManager
""" """
def setUp(self): def setUp(self):

View File

@@ -0,0 +1,216 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса SessionManagerTest
"""
import datetime
from django.core.exceptions import ImproperlyConfigured
from django.test import override_settings
from django.utils import timezone
from net.xeaf.rack.core import CoreTestCase
from net.xeaf.rack.enums import AuthRejectReason
from net.xeaf.rack.management.userstory import AccountTestData
from net.xeaf.rack.management.userstory import SessionTestData
from net.xeaf.rack.managers import SessionManager
from net.xeaf.rack.models import AccountModel
from net.xeaf.rack.models import SessionModel
from net.xeaf.rack.utils.exceptions import UnauthorizedException
class SessionManagerTests(CoreTestCase):
"""
Тесты для класса SessionManager
"""
def test_should_successfully_authorize_ivanov_with_real_password(self) -> None:
"""
Проверка успешного создания сессии для эталонного аккаунта
"""
session = SessionManager.authenticate(
username="i.ivanov@xeaf.net",
password=AccountTestData.DEFAULT_PASSWORD,
device_id="mobile_app"
)
# Проверяем, что вернулась живая сессия со сгенерированным токеном
self.assertIsNotNone(session)
self.assertEqual(len(session.token), 40)
self.assertEqual(session.account_id, AccountTestData.I_IVANOV_ID)
self.assertEqual(session.actual_account_id, AccountTestData.I_IVANOV_ID)
self.assertEqual(session.device_id, "mobile_app")
# Честно проверяем запись физически внутри БД
db_session = SessionModel.objects.get(token=session.token)
self.assertEqual(db_session.account_id, AccountTestData.I_IVANOV_ID)
def test_should_raise_error_when_password_is_incorrect(self) -> None:
"""
Менеджер должен выкинуть исключение ядра при неверном пароле
"""
with self.assertRaises(UnauthorizedException) as context:
SessionManager.authenticate(
username="i.ivanov@xeaf.net",
password="wrong_password_here"
)
self.assertIsInstance(context.exception.meta, dict)
self.assertEqual(context.exception.meta['reason'], AuthRejectReason.INVALID_USERNAME_OR_PASSWORD)
def test_should_raise_error_when_username_does_not_exist(self) -> None:
"""
Менеджер должен выкинуть исключение ядра, если email не найден
"""
with self.assertRaises(UnauthorizedException) as context:
SessionManager.authenticate(
username="unknown.user@xeaf.net",
password=AccountTestData.DEFAULT_PASSWORD
)
self.assertIsInstance(context.exception.meta, dict)
self.assertEqual(context.exception.meta['reason'], AuthRejectReason.INVALID_USERNAME_OR_PASSWORD)
def test_should_raise_error_if_account_is_inactive(self) -> None:
"""
Проверка деструктивного изменения БД: отключение аккаунта блокирует вход
"""
petrov = AccountModel.objects.get(id=AccountTestData.P_PETROV_ID)
petrov.is_active = False
petrov.save()
with self.assertRaises(UnauthorizedException) as context:
SessionManager.authenticate(
username="p.petrov@xeaf.net",
password=AccountTestData.DEFAULT_PASSWORD
)
self.assertIsInstance(context.exception.meta, dict)
self.assertEqual(context.exception.meta['reason'], AuthRejectReason.ACCOUNT_IS_NOT_ACTIVE)
def test_should_find_real_ivanov_session_by_token(self) -> None:
"""
Поиск существующей и активной сессии по токену из фикстуры
"""
session = SessionManager.find_by_token(SessionTestData.I_IVANOV_SESSION_TOKEN)
self.assertIsNotNone(session)
self.assertIsInstance(session, SessionModel)
self.assertEqual(session.token, SessionTestData.I_IVANOV_SESSION_TOKEN)
self.assertEqual(session.account_id, AccountTestData.I_IVANOV_ID)
def test_should_return_none_for_non_existent_token(self) -> None:
"""
Запрос несуществующего токена должен возвращать None
"""
result = SessionManager.find_by_token("non_existent_token_garbage_data")
self.assertIsNone(result)
def test_should_return_none_if_session_is_expired_in_db(self) -> None:
"""
Проверка деструктивного изменения БД: просроченная сессия не считывается
"""
session = SessionModel.objects.get(token=SessionTestData.P_PETROV_SESSION_TOKEN)
# Искусственно сдвигаем дату экспирации в далекое прошлое прямо в базе данных
session.expires_at = timezone.now() - datetime.timedelta(days=10)
session.save()
result = SessionManager.find_by_token(SessionTestData.P_PETROV_SESSION_TOKEN)
self.assertIsNone(result)
def test_should_fail_finding_if_session_was_physically_deleted_from_db(self) -> None:
"""
Полное физическое удаление строки из БД корректно обрабатывается
"""
SessionModel.objects.filter(token=SessionTestData.I_IVANOV_SESSION_TOKEN).delete()
result = SessionManager.find_by_token(SessionTestData.I_IVANOV_SESSION_TOKEN)
self.assertIsNone(result)
@override_settings(SESSION_TTL=1800)
def test_should_open_session_and_apply_override_settings_ttl(self) -> None:
"""
Открытие сессии должно использовать живой ConfigurationManager без заглушек
"""
ivanov = AccountModel.objects.get(id=AccountTestData.I_IVANOV_ID)
petrov = AccountModel.objects.get(id=AccountTestData.P_PETROV_ID)
# Создаем сессию имперсонации (Иванов под видом Петра)
session = SessionManager.open_session(account=ivanov, actual_account=petrov, device_id="tablet_v1")
# Проверяем, что запись успешно материализовалась в ОЗУ
db_session = SessionModel.objects.get(token=session.token)
self.assertEqual(db_session.account, ivanov)
self.assertEqual(db_session.actual_account, petrov)
self.assertEqual(db_session.device_id, "tablet_v1")
self.assertGreater(db_session.expires_at, timezone.now())
def test_should_physically_expire_session_on_close_session(self) -> None:
"""
Закрытие сессии переводит модель в статус expired прямо в БД
"""
session = SessionModel.objects.get(token=SessionTestData.I_IVANOV_SESSION_TOKEN)
SessionManager.close_session(session)
# Перечитываем чистую запись из БД
db_session = SessionModel.objects.get(token=SessionTestData.I_IVANOV_SESSION_TOKEN)
self.assertTrue(db_session.is_expired())
@override_settings(SESSION_TTL=9999)
def test_should_extend_expiration_time_on_renew_session(self) -> None:
"""
Обновление сессии сдвигает expires_at вперед на величину из настроек
"""
session = SessionModel.objects.get(token=SessionTestData.P_PETROV_SESSION_TOKEN)
initial_expires_at = session.expires_at
SessionManager.renew_session(session)
db_session = SessionModel.objects.get(token=SessionTestData.P_PETROV_SESSION_TOKEN)
self.assertGreater(db_session.expires_at, initial_expires_at)
def test_protected_create_session_model_writes_correct_record(self) -> None:
"""
Прямой вызов защищенного метода фабрики для хирургического теста записи
"""
ivanov = AccountModel.objects.get(id=AccountTestData.I_IVANOV_ID)
# Запускаем защищенный метод фабрики в обход цепочек обновления TTL
session = SessionManager._create_session_model(account=ivanov, actual_account=ivanov)
# Доказываем линтеру, что мы получили объект, и проверяем его наличие в БД
self.assertIsInstance(session, SessionModel)
self.assertTrue(SessionModel.objects.filter(token=session.token).exists())
@override_settings(AUTH_SESSION_MODEL="invalid_app.MissingModel")
def test_protected_get_session_model_class_raises_on_missing_model(self) -> None:
"""
Защищенный метод разрешения класса должен падать, если модель не найдена
"""
# Сбрасываем внутренний кэш класса в синглтоне менеджера перед тестом
SessionManager._SessionManager__model_class = None
with self.assertRaises(ImproperlyConfigured) as context:
SessionManager._get_session_model_class()
self.assertIn("must be set in settings.py", str(context.exception))