From 5c39031f04613071d17e1ae283ecc3d12a643cdf Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Tue, 14 Jul 2026 12:38:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=BA=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=20AuthRejectReason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../management/userstory/account_test_data.py | 4 +- net/xeaf/rack/managers/account_manager.py | 10 ++ net/xeaf/rack/managers/session_manager.py | 4 +- net/xeaf/rack/migrations/0001_initial.py | 4 +- .../rack/models/mixins/deleted_at_mixin.py | 5 +- .../rack/models/mixins/expired_at_mixin.py | 19 ++-- .../rack/models/mixins/updated_at_mixin.py | 5 +- net/xeaf/rack/tests/managers/__init__.py | 2 +- net/xeaf/rack/tests/utils/date_utils_tests.py | 99 +++++++++++++++++++ net/xeaf/rack/utils/__init__.py | 1 + net/xeaf/rack/utils/auth/__init__.py | 9 ++ .../auth/expiring_token_authentication.py | 61 ++++++++++++ net/xeaf/rack/utils/date_utils.py | 46 +++++++++ 13 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 net/xeaf/rack/tests/utils/date_utils_tests.py create mode 100644 net/xeaf/rack/utils/auth/__init__.py create mode 100644 net/xeaf/rack/utils/auth/expiring_token_authentication.py create mode 100644 net/xeaf/rack/utils/date_utils.py diff --git a/net/xeaf/rack/management/userstory/account_test_data.py b/net/xeaf/rack/management/userstory/account_test_data.py index 123bc42..17244d8 100644 --- a/net/xeaf/rack/management/userstory/account_test_data.py +++ b/net/xeaf/rack/management/userstory/account_test_data.py @@ -11,6 +11,7 @@ import uuid from net.xeaf.rack.models import AccountModel +from net.xeaf.rack.utils import DateUtils class AccountTestData: @@ -49,6 +50,7 @@ class AccountTestData: account.full_name = "Иван И. Иванов" account.email = "i.ivanov@xeaf.net" account.set_password(cls.DEFAULT_PASSWORD) + account.joined_at = DateUtils.now() account.save() @classmethod @@ -64,5 +66,5 @@ class AccountTestData: account.full_name = "Петр П. Петров" account.email = "p.petrov@xeaf.net" account.set_password(cls.DEFAULT_PASSWORD) + account.joined_at = DateUtils.now() account.save() - diff --git a/net/xeaf/rack/managers/account_manager.py b/net/xeaf/rack/managers/account_manager.py index ab73926..e8819de 100644 --- a/net/xeaf/rack/managers/account_manager.py +++ b/net/xeaf/rack/managers/account_manager.py @@ -16,6 +16,16 @@ class AccountManager: Реализует методы работы с Аккаунтами """ + @classmethod + def check_account(cls, username: str) -> None: + """ + Проверяет Аккаунт с указанным именем + """ + + account = AccountModel.objects.filter(username=username).first() + if account is None: + raise ValueError("Account not found") + @classmethod def restore_account(cls, account: AccountModel) -> None: """ diff --git a/net/xeaf/rack/managers/session_manager.py b/net/xeaf/rack/managers/session_manager.py index a133b10..50e66c0 100644 --- a/net/xeaf/rack/managers/session_manager.py +++ b/net/xeaf/rack/managers/session_manager.py @@ -32,7 +32,7 @@ class SessionManager[T:SessionModel]: DEFAULT_SESSION_TTL = 3600 """ Время жизни сессии по умолчанию """ - __model_class: Type[T] | None + __model_class: Type[T] | None = None """ Хранилище для класса сессии """ @classmethod @@ -131,7 +131,7 @@ class SessionManager[T:SessionModel]: return model.objects.create( token=CryptoManager.gen_token(), account=account, - actual_account=actual_account + actual_account=actual_account, ) @classmethod diff --git a/net/xeaf/rack/migrations/0001_initial.py b/net/xeaf/rack/migrations/0001_initial.py index 915813b..7e1b156 100644 --- a/net/xeaf/rack/migrations/0001_initial.py +++ b/net/xeaf/rack/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.7 on 2026-07-13 10:28 +# Generated by Django 6.0.7 on 2026-07-14 09:26 import django.contrib.auth.models import django.db.models.deletion @@ -51,7 +51,7 @@ class Migration(migrations.Migration): name='SessionModel', fields=[ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='rack.mixin.created_at')), - ('expires_at', models.DateTimeField(verbose_name='rack.mixin.expires_at')), + ('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='rack.mixin.expires_at')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='rack.mixin.id')), ('token', models.CharField(max_length=255, verbose_name='rack.models.session_model.token')), ('device_id', models.CharField(blank=True, default='WEB', max_length=36, null=True, verbose_name='rack.models.session_model.device_id')), diff --git a/net/xeaf/rack/models/mixins/deleted_at_mixin.py b/net/xeaf/rack/models/mixins/deleted_at_mixin.py index ae36bce..b7ff23a 100644 --- a/net/xeaf/rack/models/mixins/deleted_at_mixin.py +++ b/net/xeaf/rack/models/mixins/deleted_at_mixin.py @@ -9,9 +9,10 @@ """ from django.db import models -from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from net.xeaf.rack.utils import DateUtils + class DeletedAtMixin(models.Model): """ @@ -33,7 +34,7 @@ class DeletedAtMixin(models.Model): :param save: Признак необходимости сохранения """ - self.deleted_at = timezone.now() + self.deleted_at = DateUtils.now() if save: self.save() diff --git a/net/xeaf/rack/models/mixins/expired_at_mixin.py b/net/xeaf/rack/models/mixins/expired_at_mixin.py index 1f61717..94748ca 100644 --- a/net/xeaf/rack/models/mixins/expired_at_mixin.py +++ b/net/xeaf/rack/models/mixins/expired_at_mixin.py @@ -8,12 +8,11 @@ Описание класса ExpiredAtMixin """ -from datetime import timedelta - from django.db import models -from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from net.xeaf.rack.utils import DateUtils + class ExpiresAtMixin(models.Model): """ @@ -21,8 +20,8 @@ class ExpiresAtMixin(models.Model): """ expires_at = models.DateTimeField( - null=False, - blank=False, + null=True, + blank=True, editable=True, verbose_name=_("rack.mixin.expires_at"), ) @@ -40,7 +39,7 @@ class ExpiresAtMixin(models.Model): if seconds <= 0: raise ValueError(f"The seconds parameter must be greater than 0") - self.expires_at = timezone.now() + timedelta(seconds=seconds) + self.expires_at = DateUtils.shift_time(seconds=seconds) if save: self.save() @@ -51,7 +50,7 @@ class ExpiresAtMixin(models.Model): :param save: Признак необходимости сохранения """ - self.expires_at = timezone.now() + self.expires_at = DateUtils.now() if save: self.save() @@ -62,7 +61,11 @@ class ExpiresAtMixin(models.Model): :return: Признак просроченного значения """ - return self.expires_at < timezone.now() + # Если None - то значение не просрочено + if self.expires_at is None: + return False + + return self.expires_at < DateUtils.now() class Meta: """ diff --git a/net/xeaf/rack/models/mixins/updated_at_mixin.py b/net/xeaf/rack/models/mixins/updated_at_mixin.py index fef477f..2ff74a8 100644 --- a/net/xeaf/rack/models/mixins/updated_at_mixin.py +++ b/net/xeaf/rack/models/mixins/updated_at_mixin.py @@ -11,9 +11,10 @@ from datetime import datetime from django.db import models -from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from net.xeaf.rack.utils import DateUtils + class UpdatedAtMixin(models.Model): """ @@ -57,7 +58,7 @@ class UpdatedAtMixin(models.Model): :param save: Признак необходимости сохранения """ - self.updated_at = timezone.now() + self.updated_at = DateUtils.now() if save: self.save() diff --git a/net/xeaf/rack/tests/managers/__init__.py b/net/xeaf/rack/tests/managers/__init__.py index d96afab..9ad98e8 100644 --- a/net/xeaf/rack/tests/managers/__init__.py +++ b/net/xeaf/rack/tests/managers/__init__.py @@ -11,4 +11,4 @@ from .configuration_manager_tests import ConfigurationManagerTests from .crypto_manager_tests import CryptoManagerTests from .locale_manager_tests import LocaleManagerTests -# from .sesson_manager_tests import SessionManagerTests +from .sesson_manager_tests import SessionManagerTests diff --git a/net/xeaf/rack/tests/utils/date_utils_tests.py b/net/xeaf/rack/tests/utils/date_utils_tests.py new file mode 100644 index 0000000..f2da732 --- /dev/null +++ b/net/xeaf/rack/tests/utils/date_utils_tests.py @@ -0,0 +1,99 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса DateUtilsTest +""" + +import datetime +from unittest.mock import patch + +from django.test import SimpleTestCase +from django.utils import timezone + +from net.xeaf.rack.utils import DateUtils + + +class DateUtilsTestCase(SimpleTestCase): + """ + Классы для DateUtils. + """ + + def test_now_returns_frozen_current_time(self) -> None: + """ + Должен вернуть точно зафиксированное текущее время через mock.patch + """ + + fixed_time = datetime.datetime(2026, 7, 14, 12, 0, 0, tzinfo=timezone.get_current_timezone()) + + with patch("django.utils.timezone.now", return_value=fixed_time): + result = DateUtils.now() + self.assertEqual(result, fixed_time) + + def test_now_returns_timezone_aware_datetime(self) -> None: + """ + Должен возвращать объект datetime с информацией о часовом поясе (is_aware) + """ + + result = DateUtils.now() + self.assertTrue(timezone.is_aware(result)) + + def test_shift_time_uses_now_if_date_time_is_none(self) -> None: + """ + Должен автоматически взять текущее время (now), если date_time передан как None + """ + + fixed_now = datetime.datetime(2026, 7, 14, 12, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2026, 7, 17, 12, 0, 0, tzinfo=datetime.timezone.utc) + + with patch("django.utils.timezone.now", return_value=fixed_now): + result = DateUtils.shift_time(date_time=None, days=3) + self.assertEqual(result, expected_time) + + def test_shift_time_uses_now_by_omission(self) -> None: + """ + Должен автоматически взять текущее время (now), если параметр date_time вообще опущен + """ + + fixed_now = datetime.datetime(2026, 7, 14, 12, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2026, 7, 14, 14, 0, 0, tzinfo=datetime.timezone.utc) + + with patch("django.utils.timezone.now", return_value=fixed_now): + result = DateUtils.shift_time(hours=2) + self.assertEqual(result, expected_time) + + def test_shift_time_adds_days_correctly_with_explicit_date(self) -> None: + """ + Должен корректно сдвигать время вперед, если передан явный объект datetime + """ + + base_time = datetime.datetime(2026, 7, 14, 12, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2026, 7, 19, 12, 0, 0, tzinfo=datetime.timezone.utc) + + result = DateUtils.shift_time(base_time, days=5) + self.assertEqual(result, expected_time) + + def test_shift_time_subtracts_hours_correctly(self) -> None: + """ + Должен корректно сдвигать время назад при передаче отрицательных значений + """ + + base_time = datetime.datetime(2026, 7, 14, 12, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2026, 7, 14, 9, 0, 0, tzinfo=datetime.timezone.utc) + + result = DateUtils.shift_time(base_time, hours=-3) + self.assertEqual(result, expected_time) + + def test_shift_time_complex_modification(self) -> None: + """ + Должен атомарно применять комбинированный сдвиг по всем осям времени + """ + + base_time = datetime.datetime(2026, 7, 14, 12, 0, 0, tzinfo=datetime.timezone.utc) + expected_time = datetime.datetime(2026, 7, 15, 14, 30, 15, tzinfo=datetime.timezone.utc) + + result = DateUtils.shift_time(base_time, days=1, hours=2, minutes=30, seconds=15) + self.assertEqual(result, expected_time) diff --git a/net/xeaf/rack/utils/__init__.py b/net/xeaf/rack/utils/__init__.py index 65eaded..0dc1a9d 100644 --- a/net/xeaf/rack/utils/__init__.py +++ b/net/xeaf/rack/utils/__init__.py @@ -8,3 +8,4 @@ Пакет служебных классов и функций """ +from .date_utils import DateUtils diff --git a/net/xeaf/rack/utils/auth/__init__.py b/net/xeaf/rack/utils/auth/__init__.py new file mode 100644 index 0000000..de4e9e9 --- /dev/null +++ b/net/xeaf/rack/utils/auth/__init__.py @@ -0,0 +1,9 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Классов авторизации +""" diff --git a/net/xeaf/rack/utils/auth/expiring_token_authentication.py b/net/xeaf/rack/utils/auth/expiring_token_authentication.py new file mode 100644 index 0000000..d94d8bb --- /dev/null +++ b/net/xeaf/rack/utils/auth/expiring_token_authentication.py @@ -0,0 +1,61 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ExpiringTokenAuthentication +""" + +from typing import Any + +from rest_framework.authentication import TokenAuthentication +from rest_framework.request import Request + +from net.xeaf.rack.managers import LocaleManager +from net.xeaf.rack.managers import SessionManager +from net.xeaf.rack.models import AccountModel +from net.xeaf.rack.models import SessionModel + + +class ExpiringTokenAuthentication(TokenAuthentication): + """ + Реализует механизм авторизации для Токенов со сроком действия + """ + + AUTH_TOKEN_PARAM = "x-auth-token" + """ Идентификатор параметра токена """ + + def authenticate(self, request: Request) -> tuple[AccountModel | None, CoreSessionModel | None] | tuple[Any, Any] | None: + """ + Метод авторизации + + :param request: Информация о запросе + """ + + LocaleManager.adjust_request_language(request) + + key = request.query_params.get(self.AUTH_TOKEN_PARAM, None) + if key is not None: + return self.authenticate_credentials(key) + + return super().authenticate(request) + + def authenticate_credentials(self, key: str) -> tuple[AccountModel | None, CoreSessionModel | None]: + """ + Проверка Токена авторизации + + :param key: Токен авторизации + """ + + session = SessionManager[SessionModel].find_by_token(token=key) + if not session or session.is_expired(): + return None, None + + account = session.account + if not account.is_active or account.is_deleted(): + return None, None + + SessionManager.renew_session(session) + return session.account, session diff --git a/net/xeaf/rack/utils/date_utils.py b/net/xeaf/rack/utils/date_utils.py new file mode 100644 index 0000000..e12c474 --- /dev/null +++ b/net/xeaf/rack/utils/date_utils.py @@ -0,0 +1,46 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса DateUtils +""" + +import datetime + +from django.utils import timezone + + +class DateUtils: + """ + Реализует функции работы с датами и временем + """ + + @classmethod + def now(cls) -> datetime.datetime: + """ + Возвращает текущую дату и время + """ + + return timezone.now() + + @classmethod + def shift_time(cls, date_time: datetime.datetime | None = None, days: int = 0, hours: int = 0, minutes: int = 0, seconds: int = 0) -> datetime.datetime: + """ + Добавляет к дате и времени заданное количество дней, часов, минут и секунд + + :param date_time: Дата и время + :param days: Количество дней + :param hours: Количество часов + :param minutes: Количество минут + :param seconds: Количество секунд + + :return: Дата и время с добавленным временем + """ + + if date_time is None: + date_time = cls.now() + + return date_time + datetime.timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)