From 760480536c39a403120f90bf09ca47b178c10bc1 Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Sat, 11 Jul 2026 15:12:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BB=D0=B0=D1=81=D1=81=D1=8B=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D0=BD=D0=BE=D0=B2=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D1=8B=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- net/xeaf/rack/models/mixins/__init__.py | 3 + .../rack/models/mixins/expired_at_mixin.py | 2 +- net/xeaf/rack/models/mixins/slug_mixin.py | 34 ++++ .../rack/models/mixins/updated_at_mixin.py | 69 +++++++ net/xeaf/rack/models/mixins/uuid_id_mixin.py | 34 ++++ net/xeaf/rack/tests/models/mixins/__init__.py | 4 +- .../tests/models/mixins/slug_mixin_tests.py | 59 ++++++ .../models/mixins/updated_at_mixin_tests.py | 182 ++++++++++++++++++ .../models/mixins/uuid_id_mixin_tests.py | 100 ++++++++++ 9 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 net/xeaf/rack/models/mixins/slug_mixin.py create mode 100644 net/xeaf/rack/models/mixins/updated_at_mixin.py create mode 100644 net/xeaf/rack/models/mixins/uuid_id_mixin.py create mode 100644 net/xeaf/rack/tests/models/mixins/slug_mixin_tests.py create mode 100644 net/xeaf/rack/tests/models/mixins/updated_at_mixin_tests.py create mode 100644 net/xeaf/rack/tests/models/mixins/uuid_id_mixin_tests.py diff --git a/net/xeaf/rack/models/mixins/__init__.py b/net/xeaf/rack/models/mixins/__init__.py index 6b73283..621478b 100644 --- a/net/xeaf/rack/models/mixins/__init__.py +++ b/net/xeaf/rack/models/mixins/__init__.py @@ -11,3 +11,6 @@ from .created_at_mixin import CreatedAtMixin from .deleted_at_mixin import DeletedAtMixin from .expired_at_mixin import ExpiresAtMixin +from .slug_mixin import SlugMixin +from .updated_at_mixin import UpdatedAtMixin +from .uuid_id_mixin import UuidIdMixin diff --git a/net/xeaf/rack/models/mixins/expired_at_mixin.py b/net/xeaf/rack/models/mixins/expired_at_mixin.py index 59e27c8..1afbe40 100644 --- a/net/xeaf/rack/models/mixins/expired_at_mixin.py +++ b/net/xeaf/rack/models/mixins/expired_at_mixin.py @@ -17,7 +17,7 @@ from django.utils.translation import gettext_lazy as _ class ExpiresAtMixin(models.Model): """ - Реализует свойство даты и времени истечения + Добавляет к модели свойство даты и времени истечения """ expires_at = models.DateTimeField( diff --git a/net/xeaf/rack/models/mixins/slug_mixin.py b/net/xeaf/rack/models/mixins/slug_mixin.py new file mode 100644 index 0000000..c21f8ea --- /dev/null +++ b/net/xeaf/rack/models/mixins/slug_mixin.py @@ -0,0 +1,34 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса SlugMixin +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class SlugMixin(models.Model): + """ + Добавляет к модели свойство Идентификатор + """ + + slug = models.SlugField( + max_length=36, + editable=True, + blank=False, + null=False, + verbose_name=_("rack.mixin.slug"), + ) + """ Текстовый идентификатор """ + + class Meta: + """ + Конфигурация класс + """ + + abstract = True diff --git a/net/xeaf/rack/models/mixins/updated_at_mixin.py b/net/xeaf/rack/models/mixins/updated_at_mixin.py new file mode 100644 index 0000000..fef477f --- /dev/null +++ b/net/xeaf/rack/models/mixins/updated_at_mixin.py @@ -0,0 +1,69 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса UpdatedAtMixin +""" + +from datetime import datetime + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class UpdatedAtMixin(models.Model): + """ + Добавляет к модели свойство даты и времени последнего изменения + """ + + updated_at = models.DateTimeField( + null=True, + blank=True, + editable=True, + verbose_name=_("rack.mixin.updated_at"), + ) + """ Дата и время последнего изменения """ + + def is_updated(self) -> bool: + """ + Возвращает признак измененной записи + + :return: True, если запись была изменена после создания + """ + + return self.updated_at is not None + + def is_updated_since(self, dt: datetime) -> bool: + """ + Возвращает признак изменения записи после указанной даты и времени + + :param dt: Дата и время для сравнения + :return: True, если запись была изменена после указанной даты и времени + """ + + if self.updated_at is None: + return False + + return self.updated_at > dt + + def update(self, save: bool = True) -> None: + """ + Устанавливает дату и время последнего изменения на текущее время + + :param save: Признак необходимости сохранения + """ + + self.updated_at = timezone.now() + if save: + self.save() + + class Meta: + """ + Конфигурация класса + """ + + abstract = True diff --git a/net/xeaf/rack/models/mixins/uuid_id_mixin.py b/net/xeaf/rack/models/mixins/uuid_id_mixin.py new file mode 100644 index 0000000..8b49720 --- /dev/null +++ b/net/xeaf/rack/models/mixins/uuid_id_mixin.py @@ -0,0 +1,34 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса UuidIdMixin +""" + +import uuid + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class UuidIdMixin(models.Model): + """ + Добавляет к модели свойство уникального идентификатора типа UUID + """ + + id = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False, + verbose_name=_("rack.mixin.id")) + """ Уникальный идентификатор """ + + class Meta: + """ + Параметры конфигурации класса + """ + + abstract = True diff --git a/net/xeaf/rack/tests/models/mixins/__init__.py b/net/xeaf/rack/tests/models/mixins/__init__.py index d410838..6016288 100644 --- a/net/xeaf/rack/tests/models/mixins/__init__.py +++ b/net/xeaf/rack/tests/models/mixins/__init__.py @@ -11,4 +11,6 @@ from .created_at_mixin_tests import CreatedAtMixinTests from .deleted_at_mixin_tests import DeletedAtMixinTests from .expired_at_mixin_tests import ExpiresAtMixinTests - +from .slug_mixin_tests import SlugMixinTests +from .updated_at_mixin_tests import UpdatedAtMixinTests +from .uuid_id_mixin_tests import UuidIdMixinTests diff --git a/net/xeaf/rack/tests/models/mixins/slug_mixin_tests.py b/net/xeaf/rack/tests/models/mixins/slug_mixin_tests.py new file mode 100644 index 0000000..1442e2d --- /dev/null +++ b/net/xeaf/rack/tests/models/mixins/slug_mixin_tests.py @@ -0,0 +1,59 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса SlugMixinTests +""" + +from django.db import connection +from django.db import models +from django.test import TransactionTestCase + +from net.xeaf.rack.models.mixins import SlugMixin + + +class SlugMixinTests(TransactionTestCase): + """ + Тесты для миксина SlugMixin + """ + + def setUp(self): + """ + Создаем тестовую модель и таблицу для каждого теста + """ + + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = OFF;') + + class TestSlugModel(SlugMixin): + name = models.CharField(max_length=100) + + class Meta: + app_label = 'test' + db_table = 'test_slug_mixin' + + self.TestModel = TestSlugModel + + with connection.schema_editor() as editor: + editor.create_model(TestSlugModel) + + 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_slug_field_exists(self): + """ + Проверяем, что поле slug добавлено в модель + """ + + self.assertTrue(hasattr(self.TestModel, 'slug')) diff --git a/net/xeaf/rack/tests/models/mixins/updated_at_mixin_tests.py b/net/xeaf/rack/tests/models/mixins/updated_at_mixin_tests.py new file mode 100644 index 0000000..ab161a1 --- /dev/null +++ b/net/xeaf/rack/tests/models/mixins/updated_at_mixin_tests.py @@ -0,0 +1,182 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса UpdatedAtMixinTests +""" + +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 UpdatedAtMixin + + +class UpdatedAtMixinTests(TransactionTestCase): + """ + Тесты для миксина UpdatedAtMixin + """ + + def setUp(self): + """ + Создаем тестовую модель и таблицу для каждого теста + """ + + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = OFF;') + + class TestUpdatedAtModel(UpdatedAtMixin): + name = models.CharField(max_length=100) + + class Meta: + app_label = 'test' + db_table = 'test_updated_at_mixin' + + self.TestModel = TestUpdatedAtModel + + with connection.schema_editor() as editor: + editor.create_model(TestUpdatedAtModel) + + 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_updated_at_null_by_default(self): + """ + Проверяем, что при создании updated_at = None + """ + instance = self.TestModel.objects.create(name='Test Instance') + self.assertIsNone(instance.updated_at) + + def test_update_sets_current_time(self): + """ + Проверяем, что update() устанавливает текущее время + """ + + instance = self.TestModel.objects.create(name='Test Instance') + before = timezone.now() + + instance.update(save=False) + + self.assertIsNotNone(instance.updated_at) + self.assertGreaterEqual(instance.updated_at, before) + self.assertLessEqual(instance.updated_at, timezone.now()) + + def test_update_saves_to_db_when_save_true(self): + """ + Проверяем, что update(save=True) сохраняет в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.update(save=True) + instance.refresh_from_db() + + self.assertIsNotNone(instance.updated_at) + + def test_update_does_not_save_when_save_false(self): + """ + Проверяем, что update(save=False) НЕ сохраняет в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.update(save=False) + instance.refresh_from_db() + + self.assertIsNone(instance.updated_at) + + def test_update_saves_by_default(self): + """ + Проверяем, что по умолчанию update() сохраняет в БД + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.update() + instance.refresh_from_db() + + self.assertIsNotNone(instance.updated_at) + + def test_is_updated_returns_false_when_not_updated(self): + """ + Проверяем, что is_updated() возвращает False для новой записи + """ + + instance = self.TestModel.objects.create(name='Test Instance') + self.assertFalse(instance.is_updated()) + + def test_is_updated_returns_true_when_updated(self): + """ + Проверяем, что is_updated() возвращает True после update() + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.update(save=False) + self.assertTrue(instance.is_updated()) + + def test_is_updated_since_returns_false_when_not_updated(self): + """ + Проверяем, что is_updated_since() возвращает False для новой записи + """ + + instance = self.TestModel.objects.create(name='Test Instance') + dt = timezone.now() - timedelta(minutes=5) + + self.assertFalse(instance.is_updated_since(dt)) + + def test_is_updated_since_returns_true_after_update(self): + """ + Проверяем, что is_updated_since() возвращает True после update() + """ + + instance = self.TestModel.objects.create(name='Test Instance') + dt = timezone.now() + + time.sleep(0.1) + instance.update(save=False) + + self.assertTrue(instance.is_updated_since(dt)) + + def test_is_updated_since_returns_false_if_updated_before_dt(self): + """ + Проверяем, что is_updated_since() возвращает False, + если обновление было раньше указанного времени + """ + + instance = self.TestModel.objects.create(name='Test Instance') + instance.update(save=False) + old_updated_at = instance.updated_at + + # Проверяем, что updated_at был раньше, чем через секунду + dt = old_updated_at + timedelta(seconds=1) + self.assertFalse(instance.is_updated_since(dt)) + + def test_multiple_updates(self): + """ + Проверяем, что повторный вызов update() обновляет время + """ + + instance = self.TestModel.objects.create(name='Test Instance') + + instance.update(save=False) + first_time = instance.updated_at + + time.sleep(0.1) + + instance.update(save=False) + second_time = instance.updated_at + + self.assertNotEqual(first_time, second_time) + self.assertGreater(second_time, first_time) diff --git a/net/xeaf/rack/tests/models/mixins/uuid_id_mixin_tests.py b/net/xeaf/rack/tests/models/mixins/uuid_id_mixin_tests.py new file mode 100644 index 0000000..7639c79 --- /dev/null +++ b/net/xeaf/rack/tests/models/mixins/uuid_id_mixin_tests.py @@ -0,0 +1,100 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса UuidIdMixinTests +""" + +import uuid + +from django.db import connection +from django.db import models +from django.test import TransactionTestCase + +from net.xeaf.rack.models.mixins import UuidIdMixin + + +class UuidIdMixinTests(TransactionTestCase): + """ + Тесты для миксина UuidIdMixin + """ + + def setUp(self): + """ + Создаем тестовую модель и таблицу для каждого теста + """ + + with connection.cursor() as cursor: + cursor.execute('PRAGMA foreign_keys = OFF;') + + class TestUuidModel(UuidIdMixin): + name = models.CharField(max_length=100) + + class Meta: + app_label = 'test' + db_table = 'test_uuid_mixin' + + self.TestModel = TestUuidModel + + with connection.schema_editor() as editor: + editor.create_model(TestUuidModel) + + 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_id_is_uuid_by_default(self): + """ + Проверяем, что при создании id автоматически генерируется как UUID + """ + + instance = self.TestModel.objects.create(name='Test Instance') + + self.assertIsNotNone(instance.id) + self.assertIsInstance(instance.id, uuid.UUID) + + def test_id_is_unique(self): + """ + Проверяем, что каждый экземпляр получает уникальный UUID + """ + + instance1 = self.TestModel.objects.create(name='First') + instance2 = self.TestModel.objects.create(name='Second') + + self.assertNotEqual(instance1.id, instance2.id) + + def test_id_is_primary_key(self): + """ + Проверяем, что id является первичным ключом + """ + + field = self.TestModel._meta.get_field('id') + self.assertTrue(field.primary_key) + + def test_id_is_editable_false(self): + """ + Проверяем, что id нельзя редактировать + """ + + field = self.TestModel._meta.get_field('id') + self.assertFalse(field.editable) + + def test_id_can_be_used_in_queries(self): + """ + Проверяем, что можно искать по UUID + """ + + instance = self.TestModel.objects.create(name='Test Instance') + + found = self.TestModel.objects.get(id=instance.id) + self.assertEqual(found.id, instance.id)