Начало переноса классов миксинов

This commit is contained in:
2026-07-11 04:13:59 +03:00
parent b4c505048d
commit 5d30e8cbaf
12 changed files with 919 additions and 2 deletions

View File

@@ -5,7 +5,7 @@
# Все права защищены. Лицензия: MIT # Все права защищены. Лицензия: MIT
""" """
Работа с криптографией Описание класса CryptoManager
""" """
import secrets import secrets

View File

@@ -5,5 +5,5 @@
# Все права защищены. Лицензия: MIT # Все права защищены. Лицензия: MIT
""" """
Пакет описания моделей данных Пакет описания классов моделей данных
""" """

View File

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

View File

@@ -0,0 +1,34 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CreatedAtMixin
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
class CreatedAtMixin(models.Model):
"""
Добавляет к модели свойство даты и времени создания
"""
created_at = models.DateTimeField(
auto_now_add=True,
null=False,
blank=False,
editable=False,
verbose_name=_("rack.mixin.created_at"),
)
""" Дата и время создания """
class Meta:
"""
Конфигурация класса
"""
abstract = True

View File

@@ -0,0 +1,65 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса DeletedAtMixin
"""
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class DeletedAtMixin(models.Model):
"""
Добавляет к модели свойство даты и времени удаления
"""
deleted_at = models.DateTimeField(
null=True,
blank=True,
editable=True,
verbose_name=_("rack.mixin.deleted_at"),
)
""" Дата и время удаления """
def soft_delete(self, save: bool = True) -> None:
"""
Помечает запись на удаление
:param save: Признак необходимости сохранения
"""
self.deleted_at = timezone.now()
if save:
self.save()
def undelete(self, save: bool = True) -> None:
"""
Снимает пометку удаленной записи
:param save: Признак необходимости сохранения
"""
self.deleted_at = None
if save:
self.save()
def is_deleted(self) -> bool:
"""
Возвращает признак удаленной записи
:return: True, если запись была удалена
"""
return self.deleted_at is not None
class Meta:
"""
Конфигурация класса
"""
abstract = True

View File

@@ -0,0 +1,72 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ExpiredAtMixin
"""
from datetime import timedelta
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class ExpiresAtMixin(models.Model):
"""
Реализует свойство даты и времени истечения
"""
expires_at = models.DateTimeField(
null=False,
blank=False,
editable=True,
verbose_name=_("rack.mixin.expires_at"),
)
""" Дата и время истечения """
def renew(self, seconds: int, save: bool = False) -> None:
"""
Устанавливает время истечения на заданное количество секунд
:param seconds: Количество секунд до истечения
:param save: Признак необходимости сохранения
"""
# Проверка значений
if seconds <=0:
raise ValueError(f"The seconds parameter must be greater than 0")
self.expires_at = timezone.now() + timedelta(seconds=seconds)
if save:
self.save()
def expire(self, save: bool = False) -> None:
"""
Устанавливает время истечения на сейчас
:param save: Признак необходимости сохранения
"""
self.expires_at = timezone.now()
if save:
self.save()
def is_expired(self) -> bool:
"""
Возвращает признак просроченного значения
:return: Признак просроченного значения
"""
return self.expires_at < timezone.now()
class Meta:
"""
Параметры класса
"""
abstract = True

View File

@@ -7,3 +7,13 @@
""" """
Пакет описания классов тестов Пакет описания классов тестов
""" """
import sys
import warnings
if 'test' in sys.argv:
warnings.filterwarnings(
"ignore",
message="Model .* was already registered",
category=RuntimeWarning
)

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CreatedAtMixinTests
"""
import time
from datetime import datetime
from django.db import connection
from django.db import models
from django.test import TransactionTestCase
from net.xeaf.rack.models.mixins import CreatedAtMixin
class CreatedAtMixinTests(TransactionTestCase):
"""
Тесты для миксина CreatedAtMixin
"""
def setUp(self):
"""
Создаем тестовую модель и таблицу для каждого теста
"""
# Отключаем foreign keys
with connection.cursor() as cursor:
cursor.execute('PRAGMA foreign_keys = OFF;')
# Создаем тестовую модель
class TestCreatedAtModel(CreatedAtMixin):
name = models.CharField(max_length=100)
class Meta:
app_label = 'test'
db_table = 'test_created_at_mixin'
self.TestModel = TestCreatedAtModel
# Создаем таблицу
with connection.schema_editor() as editor:
editor.create_model(TestCreatedAtModel)
def tearDown(self):
"""
Удаляем таблицу после каждого теста
"""
# Удаляем таблицу
with connection.schema_editor() as editor:
editor.delete_model(self.TestModel)
# Включаем foreign keys обратно
with connection.cursor() as cursor:
cursor.execute('PRAGMA foreign_keys = ON;')
def test_created_at_is_auto_now_add(self):
"""
Проверяем, что created_at автоматически заполняется при создании
"""
instance = self.TestModel.objects.create(name='Test Instance')
self.assertIsNotNone(instance.created_at)
self.assertIsInstance(instance.created_at, datetime)
def test_created_at_is_auto_now_add_true(self):
"""
Проверяем, что auto_now_add=True
"""
field = self.TestModel._meta.get_field('created_at')
self.assertIsInstance(field, models.DateTimeField)
self.assertTrue(field.auto_now_add)
def test_created_at_editable_false(self):
"""
Проверяем, что editable=False
"""
field = self.TestModel._meta.get_field('created_at')
self.assertFalse(field.editable)
def test_different_instances_have_different_created_at(self):
"""
Проверяем, что у разных экземпляров разное время создания
"""
instance1 = self.TestModel.objects.create(name='First')
time.sleep(0.1)
instance2 = self.TestModel.objects.create(name='Second')
self.assertNotEqual(instance1.created_at, instance2.created_at)
self.assertLess(instance1.created_at, instance2.created_at)
def test_created_at_preserved_on_update(self):
"""
Проверяем, что created_at не меняется при обновлении
"""
instance = self.TestModel.objects.create(name='Test Instance')
old_created_at = instance.created_at
instance.name = 'Updated Name'
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.created_at, old_created_at)
def test_created_at_not_null(self):
"""
Проверяем, что created_at не может быть NULL
"""
field = self.TestModel._meta.get_field('created_at')
self.assertFalse(field.null)
def test_verbose_name(self):
"""
Проверяем verbose_name поля
"""
field = self.TestModel._meta.get_field('created_at')
self.assertIsInstance(field, models.Field)
self.assertEqual(field.verbose_name, "rack.mixin.created_at")

View File

@@ -0,0 +1,251 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CreatedAtMixinTests
"""
import time
from datetime import timedelta
from django.db import connection
from django.db import models
from django.test import TransactionTestCase
from django.utils import timezone
from net.xeaf.rack.models.mixins import DeletedAtMixin
class DeletedAtMixinTests(TransactionTestCase):
"""
Тесты для миксина DeletedAtMixin
"""
def setUp(self):
"""
Создаем тестовую модель и таблицу для каждого теста
"""
with connection.cursor() as cursor:
cursor.execute('PRAGMA foreign_keys = OFF;')
class TestDeletedAtModelModel(DeletedAtMixin):
name = models.CharField(max_length=100)
class Meta:
app_label = 'test'
db_table = 'test_deleted_at_mixin'
self.TestModel = TestDeletedAtModelModel
with connection.schema_editor() as editor:
editor.create_model(TestDeletedAtModelModel)
def tearDown(self):
"""
Удаляем таблицу после каждого теста
"""
with connection.schema_editor() as editor:
editor.delete_model(self.TestModel)
with connection.cursor() as cursor:
cursor.execute('PRAGMA foreign_keys = ON;')
def test_deleted_at_null_by_default(self):
"""
Проверяем, что при создании deleted_at = None
"""
instance = self.TestModel.objects.create(name='Test Instance')
self.assertIsNone(instance.deleted_at)
def test_soft_delete_sets_current_time(self):
"""
Проверяем, что soft_delete() устанавливает текущее время
"""
instance = self.TestModel.objects.create(name='Test Instance')
before = timezone.now()
instance.soft_delete(save=False)
self.assertIsNotNone(instance.deleted_at)
self.assertGreaterEqual(instance.deleted_at, before)
self.assertLessEqual(instance.deleted_at, timezone.now())
def test_soft_delete_saves_to_db_when_save_true(self):
"""
Проверяем, что при soft_delete(save=True) значение сохраняется в БД
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=True)
# Обновляем из БД
instance.refresh_from_db()
self.assertIsNotNone(instance.deleted_at)
def test_soft_delete_does_not_save_when_save_false(self):
"""
Проверяем, что при soft_delete(save=False) значение НЕ сохраняется в БД
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=False)
# Обновляем из БД
instance.refresh_from_db()
self.assertIsNone(instance.deleted_at)
def test_soft_delete_saves_by_default(self):
"""
Проверяем, что по умолчанию soft_delete() сохраняет в БД
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete()
instance.refresh_from_db()
self.assertIsNotNone(instance.deleted_at)
def test_is_deleted_returns_false_when_not_deleted(self):
"""
Проверяем, что is_deleted() возвращает False для активной записи
"""
instance = self.TestModel.objects.create(name='Test Instance')
self.assertFalse(instance.is_deleted())
def test_is_deleted_returns_true_when_deleted(self):
"""
Проверяем, что is_deleted() возвращает True для удаленной записи
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=False)
self.assertTrue(instance.is_deleted())
def test_undelete_restores_record(self):
"""
Проверяем, что undelete() восстанавливает запись
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=False)
self.assertTrue(instance.is_deleted())
instance.undelete(save=False)
self.assertFalse(instance.is_deleted())
self.assertIsNone(instance.deleted_at)
def test_undelete_saves_to_db_when_save_true(self):
"""
Проверяем, что при undelete(save=True) значение сохраняется в БД
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=True)
self.assertTrue(instance.is_deleted())
instance.undelete(save=True)
instance.refresh_from_db()
self.assertFalse(instance.is_deleted())
self.assertIsNone(instance.deleted_at)
def test_undelete_does_not_save_when_save_false(self):
"""
Проверяем, что при undelete(save=False) значение НЕ сохраняется в БД
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=True)
self.assertTrue(instance.is_deleted())
instance.undelete(save=False)
instance.refresh_from_db()
# В БД все еще deleted_at не None
self.assertTrue(instance.is_deleted())
self.assertIsNotNone(instance.deleted_at)
def test_undelete_saves_by_default(self):
"""
Проверяем, что по умолчанию undelete() сохраняет в БД
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=True)
instance.undelete()
instance.refresh_from_db()
self.assertFalse(instance.is_deleted())
self.assertIsNone(instance.deleted_at)
def test_can_set_deleted_at_manually(self):
"""
Проверяем, что можно установить deleted_at вручную
"""
custom_time = timezone.now() - timedelta(days=1)
instance = self.TestModel.objects.create(name='Test Instance')
instance.deleted_at = custom_time
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.deleted_at, custom_time)
def test_multiple_soft_deletes_update_time(self):
"""
Проверяем, что повторный вызов soft_delete() обновляет время
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=False)
first_time = instance.deleted_at
time.sleep(0.1)
instance.soft_delete(save=False)
second_time = instance.deleted_at
self.assertNotEqual(first_time, second_time)
self.assertGreater(second_time, first_time)
def test_undelete_after_manual_delete(self):
"""
Проверяем, что undelete() работает после ручной установки deleted_at
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.deleted_at = timezone.now() - timedelta(days=1)
self.assertTrue(instance.is_deleted())
instance.undelete(save=False)
self.assertFalse(instance.is_deleted())
self.assertIsNone(instance.deleted_at)
def test_soft_delete_twice_with_save(self):
"""
Проверяем, что повторный soft_delete с save=True корректно работает
"""
instance = self.TestModel.objects.create(name='Test Instance')
instance.soft_delete(save=True)
first_time = instance.deleted_at
time.sleep(0.1)
instance.soft_delete(save=True)
instance.refresh_from_db()
self.assertNotEqual(instance.deleted_at, first_time)
self.assertGreater(instance.deleted_at, first_time)

View File

@@ -0,0 +1,320 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ExpiredAtMixinTests
"""
from datetime import timedelta
from django.db import connection
from django.db import models
from django.db.models.fields import DateTimeField
from django.test import TransactionTestCase
from django.utils import timezone
from net.xeaf.rack.models.mixins import ExpiresAtMixin
class ExpiresAtMixinTests(TransactionTestCase):
"""
Тесты для миксина ExpiresAtMixin
"""
def setUp(self):
"""
Создаем тестовую модель и таблицу для каждого теста
"""
with connection.cursor() as cursor:
cursor.execute('PRAGMA foreign_keys = OFF;')
class TestExpiredAtModel(ExpiresAtMixin):
name = models.CharField(max_length=100)
class Meta:
app_label = 'test'
db_table = 'test_expires_at_mixin'
self.TestModel = TestExpiredAtModel
with connection.schema_editor() as editor:
editor.create_model(TestExpiredAtModel)
def tearDown(self):
"""
Удаляем таблицу после каждого теста
"""
with connection.schema_editor() as editor:
editor.delete_model(self.TestModel)
with connection.cursor() as cursor:
cursor.execute('PRAGMA foreign_keys = ON;')
def test_expires_at_required(self):
"""
Проверяем, что expires_at обязательное поле
"""
field = self.TestModel._meta.get_field('expires_at')
self.assertFalse(field.null)
self.assertIsInstance(field, DateTimeField)
self.assertFalse(field.blank)
def test_renew_sets_expiration_time(self):
"""
Проверяем, что renew() устанавливает время истечения
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
before = timezone.now()
instance.renew(seconds=3600, save=False)
self.assertIsNotNone(instance.expires_at)
self.assertGreaterEqual(instance.expires_at, before + timedelta(seconds=3600))
self.assertLessEqual(instance.expires_at, timezone.now() + timedelta(seconds=3600))
def test_renew_saves_to_db_when_save_true(self):
"""
Проверяем, что renew(save=True) сохраняет в БД
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
instance.renew(seconds=3600, save=True)
instance.refresh_from_db()
self.assertGreaterEqual(instance.expires_at, timezone.now())
def test_renew_does_not_save_when_save_false(self):
"""
Проверяем, что renew(save=False) НЕ сохраняет в БД
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
old_expires_at = instance.expires_at
instance.renew(seconds=3600, save=False)
instance.refresh_from_db()
self.assertEqual(instance.expires_at, old_expires_at)
def test_renew_raises_error_for_negative_seconds(self):
"""
Проверяем, что renew() выбрасывает ошибку для отрицательного значения
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
with self.assertRaises(ValueError) as context:
instance.renew(seconds=-1)
self.assertIn('greater than 0', str(context.exception))
def test_renew_raises_error_for_zero_seconds(self):
"""
Проверяем, что renew() выбрасывает ошибку для нулевого значения
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
with self.assertRaises(ValueError) as context:
instance.renew(seconds=0)
self.assertIn('greater than 0', str(context.exception))
def test_expire_sets_time_to_now(self):
"""
Проверяем, что expire() устанавливает время истечения на сейчас
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
before = timezone.now()
instance.expire(save=False)
self.assertIsNotNone(instance.expires_at)
self.assertGreaterEqual(instance.expires_at, before)
self.assertLessEqual(instance.expires_at, timezone.now())
def test_expire_saves_to_db_when_save_true(self):
"""
Проверяем, что expire(save=True) сохраняет в БД
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
instance.expire(save=True)
instance.refresh_from_db()
self.assertLessEqual(instance.expires_at, timezone.now())
def test_expire_does_not_save_when_save_false(self):
"""
Проверяем, что expire(save=False) НЕ сохраняет в БД
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
old_expires_at = instance.expires_at
instance.expire(save=False)
instance.refresh_from_db()
self.assertEqual(instance.expires_at, old_expires_at)
def test_is_expired_returns_false_when_not_expired(self):
"""
Проверяем, что is_expired() возвращает False для активной записи
"""
future_time = timezone.now() + timedelta(days=1)
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=future_time
)
self.assertFalse(instance.is_expired())
def test_is_expired_returns_true_when_expired(self):
"""
Проверяем, что is_expired() возвращает True для просроченной записи
"""
past_time = timezone.now() - timedelta(days=1)
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=past_time
)
self.assertTrue(instance.is_expired())
def test_is_expired_after_renew(self):
"""
Проверяем, что is_expired() корректно работает после renew()
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() - timedelta(days=1)
)
self.assertTrue(instance.is_expired())
instance.renew(seconds=3600, save=False)
self.assertFalse(instance.is_expired())
def test_is_expired_after_expire(self):
"""
Проверяем, что is_expired() корректно работает после expire()
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
self.assertFalse(instance.is_expired())
instance.expire(save=False)
self.assertTrue(instance.is_expired())
def test_renew_with_large_seconds(self):
"""
Проверяем, что renew() работает с большими значениями
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
seconds = 86400 * 365 # 1 год
instance.renew(seconds=seconds, save=False)
expected = timezone.now() + timedelta(seconds=seconds)
self.assertLessEqual(instance.expires_at, expected + timedelta(seconds=1))
self.assertGreaterEqual(instance.expires_at, expected - timedelta(seconds=1))
def test_renew_saves_by_default_false(self):
"""
Проверяем, что по умолчанию renew() НЕ сохраняет в БД
(save=False по умолчанию, в отличие от soft_delete)
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
old_expires_at = instance.expires_at
instance.renew(seconds=3600)
instance.refresh_from_db()
self.assertEqual(instance.expires_at, old_expires_at)
def test_expire_saves_by_default_false(self):
"""
Проверяем, что по умолчанию expire() НЕ сохраняет в БД
(save=False по умолчанию, в отличие от soft_delete)
"""
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=timezone.now() + timedelta(days=1)
)
old_expires_at = instance.expires_at
instance.expire()
instance.refresh_from_db()
self.assertEqual(instance.expires_at, old_expires_at)
def test_expires_at_editable_true(self):
"""
Проверяем, что поле expires_at редактируемое
"""
field = self.TestModel._meta.get_field('expires_at')
self.assertTrue(field.editable)
def test_can_set_expires_at_manually(self):
"""
Проверяем, что можно установить expires_at вручную
"""
custom_time = timezone.now() + timedelta(days=5)
instance = self.TestModel.objects.create(
name='Test Instance',
expires_at=custom_time
)
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.expires_at, custom_time)