From b2e15511c166027bb24a7eba3eae7c0ee4a70ea3 Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Sun, 12 Jul 2026 12:59:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=8B=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20Account,=20Session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- net/xeaf/rack/core/__init__.py | 4 + net/xeaf/rack/core/core_model.py | 26 ++++ net/xeaf/rack/core/core_persistent_model.py | 27 ++++ net/xeaf/rack/migrations/0001_initial.py | 84 +++++++++++++ net/xeaf/rack/models/__init__.py | 3 + net/xeaf/rack/models/account_model.py | 116 ++++++++++++++++++ net/xeaf/rack/models/session_model.py | 93 ++++++++++++++ net/xeaf/rack/tests/models/__init__.py | 3 + .../rack/tests/models/account_model_tests.py | 72 +++++++++++ .../rack/tests/models/session_model_tests.py | 83 +++++++++++++ wsgiapp/settings.py | 1 + 11 files changed, 512 insertions(+) create mode 100644 net/xeaf/rack/core/core_model.py create mode 100644 net/xeaf/rack/core/core_persistent_model.py create mode 100644 net/xeaf/rack/migrations/0001_initial.py create mode 100644 net/xeaf/rack/models/account_model.py create mode 100644 net/xeaf/rack/models/session_model.py create mode 100644 net/xeaf/rack/tests/models/account_model_tests.py create mode 100644 net/xeaf/rack/tests/models/session_model_tests.py diff --git a/net/xeaf/rack/core/__init__.py b/net/xeaf/rack/core/__init__.py index 656fa5e..1a67734 100644 --- a/net/xeaf/rack/core/__init__.py +++ b/net/xeaf/rack/core/__init__.py @@ -10,4 +10,8 @@ from .core_enum import CoreEnum from .core_exception import CoreException +from .core_model import CoreModel +from .core_model import CoreModel +from .core_persistent_model import CorePersistentModel +from .core_persistent_model import CorePersistentModel from .core_response import CoreResponse diff --git a/net/xeaf/rack/core/core_model.py b/net/xeaf/rack/core/core_model.py new file mode 100644 index 0000000..f4a12ab --- /dev/null +++ b/net/xeaf/rack/core/core_model.py @@ -0,0 +1,26 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreModel +""" + +from django.db import models + +from net.xeaf.rack.models.mixins import UuidIdMixin + + +class CoreModel(UuidIdMixin, models.Model): + """ + Базовый класс для всех классов моделей данных проекта + """ + + class Meta: + """ + Параметры конфигурации класса + """ + + abstract = True diff --git a/net/xeaf/rack/core/core_persistent_model.py b/net/xeaf/rack/core/core_persistent_model.py new file mode 100644 index 0000000..a53fb6d --- /dev/null +++ b/net/xeaf/rack/core/core_persistent_model.py @@ -0,0 +1,27 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CorePersistentModel +""" + +from net.xeaf.rack.models.mixins import CreatedAtMixin +from net.xeaf.rack.models.mixins import DeletedAtMixin +from net.xeaf.rack.models.mixins import UpdatedAtMixin +from .core_model import CoreModel + + +class CorePersistentModel(CoreModel, CreatedAtMixin, DeletedAtMixin, UpdatedAtMixin): + """ + Базовый класс для всех классов моделей данных с постоянным хранением + """ + + class Meta: + """ + Параметры конфигурации класса + """ + + abstract = True diff --git a/net/xeaf/rack/migrations/0001_initial.py b/net/xeaf/rack/migrations/0001_initial.py new file mode 100644 index 0000000..009dbfa --- /dev/null +++ b/net/xeaf/rack/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# Generated by Django 6.0.7 on 2026-07-12 09:19 + +import django.contrib.auth.models +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='AccountModel', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='rack.mixin.created_at')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='rack.mixin.deleted_at')), + ('description', models.TextField(blank=True, max_length=2048, null=True, verbose_name='rack.mixin.description')), + ('updated_at', models.DateTimeField(blank=True, null=True, verbose_name='rack.mixin.updated_at')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='rack.mixin.id')), + ('username', models.EmailField(default=None, max_length=254, verbose_name='rack.models.account.username')), + ('nick_name', models.CharField(blank=True, max_length=32, null=True, verbose_name='rack.models.account.nick_name')), + ('full_name', models.CharField(blank=True, max_length=127, null=True, verbose_name='rack.models.account.full_name')), + ('joined_at', models.DateTimeField(blank=True, default=None, null=True, verbose_name='rack.models.account.joined_at')), + ('last_login', models.DateTimeField(blank=True, default=None, null=True, verbose_name='rack.models.account.last_login')), + ('extra', models.JSONField(default={}, verbose_name='rack.models.account.extra')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'rack.models.account', + 'verbose_name_plural': 'rack.models.account_plural', + 'db_table': 'rack_accounts', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + 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')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='rack.mixin.id')), + ('token', models.CharField(max_length=32, 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')), + ('fcm_token', models.CharField(blank=True, default=None, max_length=256, null=True, verbose_name='rack.models.session_model.fcm_token')), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='rack.models.session_model.account')), + ('actual_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fk_session_model_actual_account', to=settings.AUTH_USER_MODEL, verbose_name='rack.models.session_model.actual_account')), + ], + options={ + 'verbose_name': 'rack.models.session', + 'verbose_name_plural': 'rack.models.session_plural', + 'db_table': 'rack_sessions', + }, + ), + migrations.AddConstraint( + model_name='accountmodel', + constraint=models.UniqueConstraint(fields=('username',), name='unique_account_username'), + ), + migrations.AddIndex( + model_name='sessionmodel', + index=models.Index(fields=['account'], name='rack_sessio_account_bd8396_idx'), + ), + migrations.AddIndex( + model_name='sessionmodel', + index=models.Index(fields=['actual_account'], name='rack_sessio_actual__7e7637_idx'), + ), + migrations.AddConstraint( + model_name='sessionmodel', + constraint=models.UniqueConstraint(fields=('token',), name='unique_session_token'), + ), + ] diff --git a/net/xeaf/rack/models/__init__.py b/net/xeaf/rack/models/__init__.py index 062b320..ebec81e 100644 --- a/net/xeaf/rack/models/__init__.py +++ b/net/xeaf/rack/models/__init__.py @@ -7,3 +7,6 @@ """ Пакет описания классов моделей данных """ + +from .account_model import AccountModel +from .session_model import SessionModel diff --git a/net/xeaf/rack/models/account_model.py b/net/xeaf/rack/models/account_model.py new file mode 100644 index 0000000..afd965b --- /dev/null +++ b/net/xeaf/rack/models/account_model.py @@ -0,0 +1,116 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса Account +""" + +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ + +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 + + +class AccountModel(AbstractUser, UuidIdMixin, CreatedAtMixin, UpdatedAtMixin, DeletedAtMixin, DescriptionMixin, ): + """ + Модель Аккаунта пользователя + """ + + first_name = None + """ Удаляем поле Имя """ + + last_name = None + """ Удаляем поле Фамилия """ + + date_joined = None + """ Удаляем поле Дата регистрации """ + + username = models.EmailField( + null=False, + default=None, + blank=False, + verbose_name=_("rack.models.account.username"), + ) + """ Имя пользователя """ + + nick_name = models.CharField( + null=True, + max_length=32, + blank=True, + verbose_name=_("rack.models.account.nick_name"), + ) + """ Псевдоним """ + + full_name = models.CharField( + null=True, + max_length=127, + blank=True, + verbose_name=_("rack.models.account.full_name"), + ) + """ Полное имя пользователя """ + + joined_at = models.DateTimeField( + null=True, + default=None, + blank=True, + verbose_name=_("rack.models.account.joined_at"), + ) + """ Дата и время завершения регистрации """ + + last_login = models.DateTimeField( + null=True, + default=None, + blank=True, + verbose_name=_("rack.models.account.last_login"), + ) + """ Дата и время последнего входа """ + + extra = models.JSONField( + null=False, + default={}, + blank=False, + verbose_name=_("rack.models.account.extra"), + ) + """ Дополнительная информация """ + + def get_full_name(self): + """ + Возвращает полное имя пользователя, + требуется для совместимости + """ + + return self.full_name + + def get_short_name(self): + """ + Возвращает краткое имя пользователя + требуется для совместимости + """ + + return self.nick_name + + class Meta: + """ + Параметры конфигурации класса + """ + + # Уникальность + constraints = [ + models.UniqueConstraint( + fields=["username"], + name="unique_account_username" + ) + ] + + # Данные таблицы + db_table = "rack_accounts" + verbose_name = _("rack.models.account") + verbose_name_plural = _("rack.models.account_plural") diff --git a/net/xeaf/rack/models/session_model.py b/net/xeaf/rack/models/session_model.py new file mode 100644 index 0000000..f18fd55 --- /dev/null +++ b/net/xeaf/rack/models/session_model.py @@ -0,0 +1,93 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса Session +""" + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from net.xeaf.rack.core import CoreModel +from net.xeaf.rack.managers import CryptoManager +from net.xeaf.rack.models.mixins import CreatedAtMixin +from net.xeaf.rack.models.mixins import ExpiresAtMixin + + +class SessionModel(CoreModel, CreatedAtMixin, ExpiresAtMixin): + """ + Модель сессии пользователя + """ + + token = models.CharField( + max_length=CryptoManager.DEFAULT_TOKEN_LENGTH, + null=False, + blank=False, + verbose_name=_("rack.models.session_model.token"), + ) + """ Токен сессии """ + + account = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=False, + blank=False, + verbose_name=_("rack.models.session_model.account"), + ) + """ Аккаунт пользователя """ + + actual_account = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=False, + blank=False, + related_name="fk_session_model_actual_account", + verbose_name=_("rack.models.session_model.actual_account"), + ) + """ Актуальный аккаунт пользователя """ + + device_id = models.CharField( + max_length=36, + default='WEB', + null=True, + blank=True, + verbose_name=_("rack.models.session_model.device_id"), + ) + """ Идентификатор устройства """ + + fcm_token = models.CharField( + max_length=256, + default=None, + null=True, + blank=True, + verbose_name=_("rack.models.session_model.fcm_token"), + ) + """ Токен для подключения к FCM """ + + class Meta: + """ + Параметры конфигурации класса + """ + + # Уникальность + constraints = [ + models.UniqueConstraint( + fields=["token"], + name="unique_session_token" + ) + ] + + # Индексы для производительности + indexes = [ + models.Index(fields=["account"]), + models.Index(fields=["actual_account"]), + ] + + # Данные таблицы + db_table = "rack_sessions" + verbose_name = _("rack.models.session") + verbose_name_plural = _("rack.models.session_plural") diff --git a/net/xeaf/rack/tests/models/__init__.py b/net/xeaf/rack/tests/models/__init__.py index 16600c9..f7dd734 100644 --- a/net/xeaf/rack/tests/models/__init__.py +++ b/net/xeaf/rack/tests/models/__init__.py @@ -7,3 +7,6 @@ """ Пакет описания тестов классов моделей данных """ + +from .account_model_tests import AccountModelTests +from .session_model_tests import SessionModelTests diff --git a/net/xeaf/rack/tests/models/account_model_tests.py b/net/xeaf/rack/tests/models/account_model_tests.py new file mode 100644 index 0000000..681e6ba --- /dev/null +++ b/net/xeaf/rack/tests/models/account_model_tests.py @@ -0,0 +1,72 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса AccountModelTests +""" + +from django.db.utils import IntegrityError +from django.test import TestCase + +from net.xeaf.rack.models import AccountModel + + +class AccountModelTests(TestCase): + """ + Тесты для класса AccountModel + """ + + def test_create_account_with_defaults(self): + """ + Проверяет корректное создание аккаунта с дефолтными полями + """ + + email = "test_user@example.com" + + # Создаем через чистый ORM, как ты любишь, построчно + account = AccountModel() + account.username = email + account.save() + + # Проверяем контракт полей + self.assertEqual(account.username, email) + self.assertIsNone(account.full_name) + self.assertIsNone(account.nick_name) + + # Если ты исправил дефолт на dict, тест гарантирует безопасность: + self.assertEqual(account.extra, {}) + + def test_compatibility_methods(self): + """ + Проверяет методы совместимости get_full_name и get_short_name + """ + + account = AccountModel() + account.username = "names@example.com" + account.full_name = "Иван Иванов" + account.nick_name = "vanya" + account.save() + + self.assertEqual(account.get_full_name(), "Иван Иванов") + self.assertEqual(account.get_short_name(), "vanya") + + def test_username_uniqueness_constraint(self): + """ + Проверяет жесткое ограничение уникальности на уровне UniqueConstraint + """ + + duplicate_email = "duplicate@example.com" + + # Создаем первого + account1 = AccountModel(username=duplicate_email) + account1.save() + + # Попытка создать второго с тем же username должна намертво упасть в БД + account2 = AccountModel(username=duplicate_email) + + with self.assertRaises(IntegrityError): + account2.save() + diff --git a/net/xeaf/rack/tests/models/session_model_tests.py b/net/xeaf/rack/tests/models/session_model_tests.py new file mode 100644 index 0000000..530d8be --- /dev/null +++ b/net/xeaf/rack/tests/models/session_model_tests.py @@ -0,0 +1,83 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса SessionModelTests +""" + +from django.db.utils import IntegrityError +from django.test import TestCase + +from net.xeaf.rack.managers import CryptoManager +from net.xeaf.rack.models import AccountModel +from net.xeaf.rack.models import SessionModel + + +class SessionModelTests(TestCase): + """ + Тесты для класса SessionModel + """ + + def setUp(self): + """ + Атомарно создаем тестовых пользователей для связей + """ + + self.primary_user = AccountModel.objects.create_user( + username="primary@example.com", + password="secure_password_123" + ) + self.actual_user = AccountModel.objects.create_user( + username="actual@example.com", + password="secure_password_456" + ) + + def test_create_session_with_defaults_and_relations(self): + """ + Проверяет успешное создание сессии, заполнение дефолтных полей и внешних ключей + """ + + token_str = CryptoManager.gen_token() + + session = SessionModel() + session.token = token_str + session.account = self.primary_user + session.actual_account = self.actual_user + session.renew(3600) + session.save() + + # Проверяем контракт полей и связей + self.assertEqual(session.token, token_str) + self.assertEqual(session.account.id, self.primary_user.id) + self.assertEqual(session.actual_account.id, self.actual_user.id) + self.assertEqual(session.device_id, "WEB") + + def test_token_uniqueness_constraint(self): + """ + Проверяет жесткое ограничение уникальности токена сессии в базе данных + """ + + duplicate_token = "same_token_xyz" + + # Создаем первую валидную сессию + session1 = SessionModel( + token=duplicate_token, + account=self.primary_user, + actual_account=self.actual_user + ) + session1.renew(3600) + session1.save() + + # Попытка создать вторую сессию с тем же токеном должна намертво упасть в БД + session2 = SessionModel( + token=duplicate_token, + account=self.primary_user, + actual_account=self.actual_user + ) + + with self.assertRaises(IntegrityError): + session2.renew(3600) + session2.save() diff --git a/wsgiapp/settings.py b/wsgiapp/settings.py index d0afe58..5f5a01c 100644 --- a/wsgiapp/settings.py +++ b/wsgiapp/settings.py @@ -99,6 +99,7 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +AUTH_USER_MODEL = 'net_xeaf_rack.AccountModel' # Internationalization # https://docs.djangoproject.com/en/6.0/topics/i18n/