Compare commits

66 Commits

Author SHA1 Message Date
304fa18bce Добавлены тесты для Environment 2026-07-20 15:33:46 +03:00
781e885ffb Добавлены тесты для CoreCommand 2026-07-20 12:05:57 +03:00
05ce5b7cf4 Незначительные исправления для AccountModel 2026-07-20 11:00:29 +03:00
2d58667e49 Добавлены тесты для CoreException 2026-07-20 02:26:49 +03:00
2d0911a574 Добавлены тесты для CoreResponse 2026-07-20 01:21:20 +03:00
b923cac032 Добавлены тесты для CoreEnum 2026-07-19 23:58:20 +03:00
785bf1ba29 Добавлены тесты для CoreEnum 2026-07-19 23:52:34 +03:00
a25b34ab3f Перенос служебных методов настроек 2026-07-19 20:56:29 +03:00
9939aa5799 Перенос служебных методов настроек 2026-07-19 20:55:25 +03:00
9e71bb6e29 Перенос служебных методов настроек 2026-07-19 20:45:46 +03:00
da3f3aa703 Исправлен фильтр журнала отладки 2026-07-19 20:20:57 +03:00
e40d5a4159 Исправлен формат представления лога 2026-07-19 20:10:44 +03:00
a6c9db1a71 Исправление двойного логирования ошибок 2026-07-19 19:45:03 +03:00
7c13582fdb Рефакторинг - перенос пакета utils.drf в middleware 2026-07-19 14:33:30 +03:00
1d95a70282 Добавлен средний слой с логированием данных 2026-07-19 14:28:09 +03:00
9dbbefedd3 Инспекция кода 2026-07-18 23:06:38 +03:00
7c0f32d1fc Исправление обработчика pure_status для ошибок 2026-07-18 23:05:39 +03:00
a8d36b59b4 Добавление custom_exception_handler 2026-07-18 22:44:06 +03:00
7f87794fcc Инспекция кода 2026-07-18 21:15:50 +03:00
02087aa80a Инспекция кода 2026-07-18 21:12:33 +03:00
a5a29b03b6 Конфигурация приложения перенесена в библиотеку 2026-07-18 20:05:17 +03:00
a95793c3df Начало разработки обработчика настроек приложения 2026-07-18 16:28:50 +03:00
f0a6e4d97d Добавлено представление получения информации о версии проекта 2026-07-18 12:55:47 +03:00
cc57591e66 Инспекция кода 2026-07-18 11:50:42 +03:00
87a4f06d40 Исправлен алгоритм подсчета ошибок для rack_coverage 2026-07-18 11:15:51 +03:00
ba8da9c8c9 Переименование служебных файлов для команд 2026-07-18 01:56:47 +03:00
32ac575178 Улучшение отчета для rack_coverage 2026-07-18 01:00:02 +03:00
7428bc251f Изменение параметров для coverage 2026-07-18 00:58:33 +03:00
ac94627356 Изменение параметров для coverage 2026-07-17 23:34:22 +03:00
3d52df7687 Удаление тестовых данных 2026-07-17 23:18:36 +03:00
3324dbe9fb Завершение доработки команды rack_coverage 2026-07-17 23:18:02 +03:00
11b332be2d Улучшение отчета для rack_coverage 2026-07-17 22:32:42 +03:00
579d014dde Начальная версия rack_coverage 2026-07-17 21:05:13 +03:00
aeebd28c52 Добавления проверки покрытия тестами 2026-07-17 15:02:15 +03:00
0c1c2bd61c Подготовка новых моделей тестов 2026-07-17 13:23:17 +03:00
b68f17f069 Исправление ошибки в тестах 2026-07-17 10:46:55 +03:00
3d1e6b47bd Добавлен класс RequestManager 2026-07-16 19:53:53 +03:00
63537f24a0 Исправление синтаксиса 2026-07-16 11:55:03 +03:00
b713a2124b Добавлен класс CoreViewSet и тесты к нему 2026-07-16 11:52:51 +03:00
4ce8a1ba58 Добавлен класс ViewSetAction 2026-07-14 18:03:12 +03:00
9a9f25c69e Добавление служебных методов для тестирования 2026-07-14 17:32:16 +03:00
3af5f9988a Добавлен класс ExpiredTokenAuthentication 2026-07-14 17:29:54 +03:00
5330137041 Доработка тестов для ExpiredAtMixin 2026-07-14 13:02:41 +03:00
54d717369a Отлажен класс SessionManager 2026-07-14 12:56:07 +03:00
5c39031f04 Подготовка к замене AuthRejectReason 2026-07-14 12:38:23 +03:00
c0cefe2f32 Классы моделей исправлены 2026-07-13 14:11:28 +03:00
f0db1ba9b5 Классы моделей исправлены 2026-07-13 13:30:11 +03:00
c5e4c88588 Подготовка к переносу классов моделей 2026-07-13 12:43:19 +03:00
c9a75b3370 Подготовка к реализации авторизации сессии 2026-07-13 01:52:04 +03:00
1acd7c0029 Добавлен класс ConfigurationManager 2026-07-12 20:37:43 +03:00
9c13222d0d Фиксируем модели, UserStory и тесты 2026-07-12 15:50:41 +03:00
b2e15511c1 Добавлены классы моделей данных Account, Session 2026-07-12 12:59:06 +03:00
164598294a Добавлены классы ответов 2026-07-12 03:50:57 +03:00
b816a7766a Исключение тестов из сборки Wheel 2026-07-11 23:15:44 +03:00
7dfd4a242b Отказ от CoreTestCase 2026-07-11 23:04:30 +03:00
c90abe11f8 Добавлены классы стандартных исключений 2026-07-11 22:55:42 +03:00
3fba091cbc Добавлен класс CoreException 2026-07-11 18:34:54 +03:00
240ae2f448 Добавлен класс DescriptionMixin 2026-07-11 16:32:18 +03:00
760480536c Классы миксинов перенесены в проект 2026-07-11 15:12:30 +03:00
5d30e8cbaf Начало переноса классов миксинов 2026-07-11 04:13:59 +03:00
b4c505048d Класс Crypto переименован в CryptoManager 2026-07-10 23:31:44 +03:00
31a13c7ccc Добавление класса LocaleManager 2026-07-10 23:02:17 +03:00
ef09fb4de3 Добавление классов CoreEnum и UILanguage 2026-07-10 18:43:57 +03:00
0370f20efd Продолжение работы над Crypto 2026-07-10 16:02:22 +03:00
15cc114656 Добавлен служебный класс Crypto и тесты к нему 2026-07-10 15:08:00 +03:00
8a86b517fc Начальное состояние проекта 2026-07-10 14:37:51 +03:00
149 changed files with 9597 additions and 43 deletions

17
.coveragerc Normal file
View File

@@ -0,0 +1,17 @@
[run]
# Укажи папки со своей бизнес-логикой
source = net
# Игнорируем миграции, тесты и системные файлы Django
omit =
*/migrations/*
*/tests/*
*/testing/*
*/configs/*
*/apps.py
manage.py
[report]
# Принудительно выводить номера строк, которые НЕ покрыты тестами
show_missing = False

View File

@@ -10,9 +10,7 @@ trim_trailing_whitespace = true
[*.html] [*.html]
max_line_length = 240 max_line_length = 240
trim_trailing_whitespace = true
[*.md] [*.md]
max_line_length = off max_line_length = off
trim_trailing_whitespace = true

17
.rackcoveragerc Normal file
View File

@@ -0,0 +1,17 @@
[rack-coverage]
# Пакеты для сканирования
packages =
net.xeaf.rack
# То, что мы выбрасываем (маски исключения)
exclude =
*.__*
*.get_absolute_url
net.xeaf.rack.core.testing.*
net.xeaf.rack.utils.settings.config.*
# То, что мы ПРИНУДИТЕЛЬНО возвращаем назад (маски включения)
# Этот список имеет наивысший приоритет!
include =
*.__init__
*.__call__

View File

@@ -10,42 +10,42 @@
## Таблица. Перечень сторонних библиотек и компонентов ## Таблица. Перечень сторонних библиотек и компонентов
| № п/п | Наименование компонента | Версия | Тип лицензии | Функциональное назначение компонента | | № п/п | Наименование компонента | Версия | Тип лицензии | Функциональное назначение компонента |
|---|---|---|---|---| |------:|---------------------------------|-------------|---------------|------------------------------------------------------------|
| 1 | Django | 6.0.7 | BSD-3-Clause | Основной веб-фреймворк архитектуры ПО | | 1 | Django | 6.0.7 | BSD-3-Clause | Основной веб-фреймворк архитектуры ПО |
| 2 | djangorestframework | 3.17.1 | BSD-3-Clause | Построение REST API интерфейсов | | 2 | djangorestframework | 3.17.1 | BSD-3-Clause | Построение REST API интерфейсов |
| 3 | asgiref | 3.11.1 | BSD License | Поддержка асинхронных интерфейсов веб-сервера | | 3 | asgiref | 3.11.1 | BSD License | Поддержка асинхронных интерфейсов веб-сервера |
| 4 | boto3 | 1.43.44 | Apache-2.0 | Интеграция и управление облачным объектным хранилищем | | 4 | boto3 | 1.43.44 | Apache-2.0 | Интеграция и управление облачным объектным хранилищем |
| 5 | botocore | 1.43.44 | Apache-2.0 | Ядро низкоуровневых запросов к облачной инфраструктуре | | 5 | botocore | 1.43.44 | Apache-2.0 | Ядро низкоуровневых запросов к облачной инфраструктуре |
| 6 | s3transfer | 0.19.0 | Apache-2.0 | Управление многопоточной загрузкой файлов в хранилище | | 6 | s3transfer | 0.19.0 | Apache-2.0 | Управление многопоточной загрузкой файлов в хранилище |
| 7 | django-storages | 1.14.6 | BSD License | Подключение внешних хранилищ данных к медиа-файлам | | 7 | django-storages | 1.14.6 | BSD License | Подключение внешних хранилищ данных к медиа-файлам |
| 8 | concurrent-log-handler | 0.9.29 | Apache-2.0 | Безопасное многопоточное логирование процессов | | 8 | concurrent-log-handler | 0.9.29 | Apache-2.0 | Безопасное многопоточное логирование процессов |
| 9 | django-cors-headers | 4.9.0 | MIT | Регулирование правил кросс-доменных запросов (CORS) | | 9 | django-cors-headers | 4.9.0 | MIT | Регулирование правил кросс-доменных запросов (CORS) |
| 10 | django-environ | 0.14.0 | MIT | Безопасная конфигурация проекта через переменные окружения | | 10 | django-environ | 0.14.0 | MIT | Безопасная конфигурация проекта через переменные окружения |
| 11 | django-split-settings | 1.3.2 | BSD License | Модульное разделение настроек проекта по окружениям | | 11 | django-split-settings | 1.3.2 | BSD License | Модульное разделение настроек проекта по окружениям |
| 12 | split-settings | 1.0.0 | BSD License | Вспомогательная утилита разделения конфигурационных файлов | | 12 | split-settings | 1.0.0 | BSD License | Вспомогательная утилита разделения конфигурационных файлов |
| 13 | gunicorn | 26.0.0 | MIT | WSGI-сервер для развертывания веб-приложения | | 13 | gunicorn | 26.0.0 | MIT | WSGI-сервер для развертывания веб-приложения |
| 14 | jmespath | 1.1.0 | MIT | Декларативный синтаксис для фильтрации JSON-данных | | 14 | jmespath | 1.1.0 | MIT | Декларативный синтаксис для фильтрации JSON-данных |
| 15 | packaging | 26.2 | BSD-2-Clause | Парсинг и валидация версий пакетов зависимостей | | 15 | packaging | 26.2 | BSD-2-Clause | Парсинг и валидация версий пакетов зависимостей |
| 16 | python-dateutil | 2.9.0.post0 | Apache-2.0 | Расширенная работа с форматами даты и часовыми поясами | | 16 | python-dateutil | 2.9.0.post0 | Apache-2.0 | Расширенная работа с форматами даты и часовыми поясами |
| 17 | pillow | 12.3.0 | MIT-CMU | Обработка, оптимизация и валидация графических файлов | | 17 | pillow | 12.3.0 | MIT-CMU | Обработка, оптимизация и валидация графических файлов |
| 18 | portalocker | 3.2.0 | BSD-3-Clause | Кроссплатформенная блокировка файлов при записи | | 18 | portalocker | 3.2.0 | BSD-3-Clause | Кроссплатформенная блокировка файлов при записи |
| 19 | psycopg | 3.3.4 | LGPL-3.0-only | См. Особые примечания (Драйвер СУБД) | | 19 | psycopg | 3.3.4 | LGPL-3.0-only | См. Особые примечания (Драйвер СУБД) |
| 20 | psycopg-binary | 3.3.4 | LGPL-3.0-only | См. Особые примечания (Драйвер СУБД) | | 20 | psycopg-binary | 3.3.4 | LGPL-3.0-only | См. Особые примечания (Драйвер СУБД) |
| 21 | pywin32 | 312 | PSF License | Обеспечение совместимости со средой разработки Windows | | 21 | pywin32 | 312 | PSF License | Обеспечение совместимости со средой разработки Windows |
| 22 | redis (Python-пакет) | 8.0.1 | MIT | Клиентская библиотека сетевого протокола RESP | | 22 | redis (Python-пакет) | 8.0.1 | MIT | Клиентская библиотека сетевого протокола RESP |
| 23 | KeyDB (Инфраструктура) | любая | BSD-3-Clause | База данных в ОЗУ для кэширования (вместо ПО Redis) | | 23 | KeyDB (Инфраструктура) | любая | BSD-3-Clause | База данных в ОЗУ для кэширования (вместо ПО Redis) |
| 24 | six | 1.17.0 | MIT | Утилита совместимости кодовой базы | | 24 | six | 1.17.0 | MIT | Утилита совместимости кодовой базы |
| 25 | sqlparse | 0.5.5 | BSD License | Форматирование и невалидирующий парсинг SQL-запросов | | 25 | sqlparse | 0.5.5 | BSD License | Форматирование и невалидирующий парсинг SQL-запросов |
| 26 | typing_extensions | 4.16.0 | PSF-2.0 | Поддержка продвинутой статической типизации кода | | 26 | typing_extensions | 4.16.0 | PSF-2.0 | Поддержка продвинутой статической типизации кода |
| 27 | tzdata | 2026.2 | Apache-2.0 | Системный провайдер актуальной базы часовых поясов | | 27 | tzdata | 2026.2 | Apache-2.0 | Системный провайдер актуальной базы часовых поясов |
| 28 | urllib3 | 2.7.0 | MIT | Клиент HTTP-запросов к внешним сервисам | | 28 | urllib3 | 2.7.0 | MIT | Клиент HTTP-запросов к внешним сервисам |
| 29 | djangorestframework-camel-case | 1.4.2 | BSD License | Автоматическая конвертация стилей JSON-ключей в API | | 29 | djangorestframework-camel-case | 1.4.2 | BSD License | Автоматическая конвертация стилей JSON-ключей в API |
| 30 | djangorestframework-dataclasses | 1.4.0 | BSD License | Интеграция структур данных Dataclasses в сериализаторы | | 30 | djangorestframework-dataclasses | 1.4.0 | BSD License | Интеграция структур данных Dataclasses в сериализаторы |
| 31 | drf-rack | 0.1.0 | MIT | Дополнительные инструменты профилирования REST API | | 31 | drf-rack | 0.1.0 | MIT | Дополнительные инструменты профилирования REST API |
| 32 | django-stubs / ext | 6.0.6 | MIT | Аннотации типов для статического анализатора кода | | 32 | django-stubs / ext | 6.0.6 | MIT | Аннотации типов для статического анализатора кода |
| 33 | djangorestframework-stubs | 317.0 | MIT | Аннотации типов для валидации API-слоя | | 33 | djangorestframework-stubs | 317.0 | MIT | Аннотации типов для валидации API-слоя |
| 34 | types-PyYAML | 6.0.12 | Apache-2.0 | Аннотации типов конфигурационных файлов YAML | | 34 | types-PyYAML | 6.0.12 | Apache-2.0 | Аннотации типов конфигурационных файлов YAML |
## Особые примечания по компонентам с лицензией LGPL (psycopg / psycopg-binary) ## Особые примечания по компонентам с лицензией LGPL (psycopg / psycopg-binary)
@@ -55,7 +55,7 @@
(посредством штатных инструкций импорта). (посредством штатных инструкций импорта).
Исходный текст самих компонентов модификации не подвергается, в состав разрабатываемого ПО статически Исходный текст самих компонентов модификации не подвергается, в состав разрабатываемого ПО статически
не интегрируется и загружается изолированно из официального публичного репозитория.В соответствии с не интегрируется и загружается изолированно из официального публичного репозитория. В соответствии с
условиями Раздела 4 лицензии GNU Lesser General Public License (LGPLv3), данная схема динамического условиями Раздела 4 лицензии GNU Lesser General Public License (LGPLv3), данная схема динамического
связывания является правомерной, не распространяет действие условий указанной лицензии на разработанное ПО, связывания является правомерной, не распространяет действие условий указанной лицензии на разработанное ПО,
не накладывает обязательств по предоставлению исходного текста основного программного продукта и не не накладывает обязательств по предоставлению исходного текста основного программного продукта и не

92
docker-compose.env Normal file
View File

@@ -0,0 +1,92 @@
#
# URLs
#
RACK_API_URL='http://localhost:8000'
#
# Security
#
RACK_DEBUG=True
RACK_SECRET_KEY='<change-me>'
RACK_ALLOWED_HOSTS='localhost, 127.0.0.1'
RACK_CORS_ALLOWED_ORIGINS='http://localhost:4200, http://127.0.0.1:4200'
RACK_CSRF_TRUSTED_ORIGINS='http://localhost:4200, http://127.0.0.1:4200'
RACK_SESSION_TIMEOUT=1209600
#
# Main Database
#
RACK_DATABASE_HOST='localhost'
RACK_DATABASE_PORT=5432
RACK_DATABASE_NAME='rack'
RACK_DATABASE_USERNAME='rack'
RACK_DATABASE_PASSWORD='<change-me>'
#
# Test Database
#
RACK_TEST_DATABASE='/tmp/rack/rack.sqlite'
#
# KeyDb Database
#
RACK_USE_KEYDB=1
RACK_KEYDB_HOST='127.0.0.1'
RACK_KEYDB_PORT=6379
RACK_KEYDB_DBINDEX=0
RACK_KEYDB_PASSWORD='<change-me>'
RACK_KEYDB_POOL_SIZE=4
#
# S3 Object Storage
#
USE_S3=1
RACK_S3_ENDPOINT_URL='http://localhost:9000'
RACK_MINIO_API_PORT=9000
RACK_MINIO_CONSOLE_PORT=9001
RACK_S3_ACCESS_KEY='minioadmin'
RACK_S3_SECRET_KEY='<change-me>'
RACK_S3_BUCKET_PREFIX='<change-me>'
RACK_S3_POOL_SIZE=4
#
# I10N and L10N
#
RACK_LANGUAGE='ru'
RACK_TIME_ZONE='Europe/Moscow'
#
# Static & Media files
#
RACK_STATIC_URL='assets/'
RACK_STATIC_ROOT='/tmp/rack/static'
RACK_MEDIA_ROOT='/tmp/rack/media'
RACK_THUMBNAILS_ROOT='/tmp/rack/thumbnails'
#
# Email SMTP
#
RACK_EMAIL_HOST='localhost'
RACK_EMAIL_PORT=1025
RACK_EMAIL_HOST_USER=''
RACK_EMAIL_HOST_PASSWORD=''
RACK_EMAIL_USE_TLS=False
RACK_EMAIL_USE_SSL=False
RACK_EMAIL_TIMEOUT=20
RACK_EMAIL_SSL_KEYFILE=''
RACK_EMAIL_SSL_CERTFILE=''
#
# Email Sender
#
RACK_EMAIL_FROM_NAME='DRF Rack'
RACK_EMAIL_FROM_ADDR='rack@xeaf.net'
RACK_EMAIL_SUBJECT_PREFIX='[DRF Rack]'
#
# Logs
#
RACK_LOG_LEVEL='DEBUG'
RACK_LOG_FILE='/tmp/rack/rack.log'
RACK_DEBUG_LOG_FILE='/tmp/rack/rack_debug.log'

121
docker-compose.yml Normal file
View File

@@ -0,0 +1,121 @@
#
# Службы
#
services:
# 1. База данных (Источник правды)
db :
image : postgres:15-alpine
container_name: rack_db
restart : always
env_file :
- .env
environment :
POSTGRES_DB : ${RACK_DATABASE_NAME}
POSTGRES_USER : ${RACK_DATABASE_USERNAME}
POSTGRES_PASSWORD: ${RACK_DATABASE_PASSWORD}
ports :
- "${rack_DATABASE_PORT:-5432}:5432"
volumes :
- postgres_data:/var/lib/postgresql/data
networks :
- rack-network
# 2. KeyDB
keydb :
image : eqalpha/keydb:latest
container_name: rack_keydb
restart : always
env_file :
- .env
ports :
- "${RACK_KEYDB_PORT:-6379}:6379"
command : keydb-server --protected-mode no --save ""
volumes :
- keydb_data:/data
networks :
- rack-network
# 3. MinIO (S3 Хранилище)
minio :
image : minio/minio:latest # Всегда доступный и свежий образ
container_name: rack_minio
restart : always
env_file :
- .env
environment :
MINIO_ROOT_USER : ${rack_S3_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${rack_S3_SECRET_KEY}
ports :
- "${rack_MINIO_API_PORT:-9000}:9000"
- "${rack_MINIO_CONSOLE_PORT:-9001}:9001"
volumes :
- minio_data:/data
command : server /data --console-address ":9001"
healthcheck :
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks :
- rack-network
# 4. Авто-создатель бакетов
minio-init:
image : minio/mc:latest
container_name: rack_minio_mc
depends_on :
minio:
condition: service_healthy
env_file :
- .env
entrypoint : >
/bin/sh -c "
echo 'Настройка подключения к MinIO...';
/usr/bin/mc alias set local http://minio:9000 ${rack_S3_ACCESS_KEY} ${rack_S3_SECRET_KEY};
echo 'Ожидание готовности сервера...';
while ! /usr/bin/mc ping local --count 1 > /dev/null 2>&1; do
sleep 1;
done;
echo 'Сервер готов. Создание бакетов...';
/usr/bin/mc mb local/${rack_S3_BUCKET_PREFIX}-avatar --ignore-existing || true;
/usr/bin/mc mb local/${rack_S3_BUCKET_PREFIX}-media --ignore-existing || true;
echo 'Бакеты успешно проверены/созданы!';
exit 0;
"
networks :
- rack-network
# 5. Почтовый сервер (Ловушка для писем)
mailpit :
image : axllent/mailpit:latest
container_name: rack_mail
restart : always
env_file :
- .env
ports :
- "${rack_MAILPIT_UI_PORT:-8025}:8025"
- "${rack_MAILPIT_SMTP_PORT:-1025}:1025"
networks :
- rack-network
#
# Тома
#
volumes :
postgres_data:
keydb_data :
minio_data :
#
# Сеть
#
networks:
rack-network:
driver: bridge
name : rack-network

22
manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wsgiapp.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

9
net/__init__.py Normal file
View File

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

9
net/xeaf/__init__.py Normal file
View File

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

View File

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

21
net/xeaf/rack/apps.py Normal file
View File

@@ -0,0 +1,21 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Класс конфигурации приложения библиотеки
"""
from django.apps import AppConfig
class RackConfig(AppConfig):
"""
Конфигурация приложения библиотеки DRF Rack
"""
name = "net.xeaf.rack"
label = "net_xeaf_rack"
verbose_name = "DRF Rack"

View File

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

View File

@@ -0,0 +1,81 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreCommand
"""
import sys
from django.conf import settings
from django.core.management import BaseCommand
from django.core.management import call_command
class CoreCommand(BaseCommand):
"""
Реализует базовые свойства всех обработчиков команд
"""
# noinspection PyMethodMayBeStatic
def _clear_database(self):
"""
Очищает базу данных
"""
call_command("flush", "--no-input")
@classmethod
def _is_debug_or_test(cls):
"""
Возвращает признак режима отладки или тестов
"""
return settings.DEBUG or 'test' in sys.argv
@classmethod
def _report_separator(cls, length: int = 70, double: bool = True, first_new_line: bool = False, last_new_line: bool = False) -> None:
"""
Выводит разделитель отчёта
:param length Длина разделителя
:param double Выводить ли двойной разделитель
:param first_new_line Выводить ли первую пустую строку
:param last_new_line Выводить ли последнюю пустую строку
"""
if first_new_line:
print()
if double:
print('=' * length)
else:
print('-' * length)
if last_new_line:
print()
@classmethod
def _report_int_value(cls, name: str, value: int, length: int = 70) -> None:
"""
Выводит значение отчёта
:param name Название
:param value Значение
:param length Общая длина
"""
if not name:
p_len = length - len(str(value)) - 1
else:
p_len = length - len(str(value)) - len(name) - 2
if p_len <= 0:
points = ""
else:
points = " " + ("." * p_len) + " "
print(f"{name}{points}{value}")

View File

@@ -0,0 +1,81 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreEnum
"""
from enum import StrEnum
class CoreEnum(StrEnum):
"""
Реализует базовые методы для классов перечислений
"""
@classmethod
def choices(cls) -> list[tuple[str, str]]:
"""
Возвращает список для организации выбора значения
:return: Список для выбора значения
"""
cls._check_is_empty()
return [(item.value, item.name) for item in cls]
@classmethod
def keys(cls) -> list[str]:
"""
Возвращает список ключей
:return: Список ключей
"""
cls._check_is_empty()
return [item.name for item in cls]
@classmethod
def values(cls) -> list[str]:
"""
Возвращает список значений
:return: Список значений
"""
cls._check_is_empty()
return [item.value for item in cls.__members__.values()]
@classmethod
def from_string(cls, value: str) -> CoreEnum:
"""
Возвращает перечисление по строковому значению
:param value: Строковое значение
:return: Перечисление
"""
cls._check_is_empty()
for item in cls:
if item.value == value:
return item
raise ValueError(f"The {cls.__name__} enum does not contain the value '{value}'")
@classmethod
def _check_is_empty(cls):
"""
Проверяет, что перечисление не пустое
:return: True, если перечисление не пустое
"""
count = len(cls)
if count == 0:
raise ValueError(f"The {cls.__name__} enum must not be empty.")

View File

@@ -0,0 +1,68 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreException
"""
from typing import Any
class CoreException(Exception):
"""
Реализует базовый функционал всех классов исключений проекта
"""
status_code: int
""" Код состояния ответа API """
detail: str | dict | list | None = None
""" Информация об ошибке """
meta: dict | None = None
""" Информация для обработки ошибки """
def __init__(self, status_code: int, detail: str | dict | list | None = None, meta: dict | None = None, **kwargs: Any):
"""
Инициализация
:param status_code: Код состояния ответа API
:param detail: Информация об ошибке
:param meta: Информация для обработки ошибки
:param kwargs: Дополнительные параметры
"""
self.status_code = status_code
self.detail = detail
self._process_meta(meta, **kwargs)
super().__init__()
def is_pure_status(self) -> bool:
"""
Возвращает признак необходимости возвращать в ответе реальный статус HTTP
:return: Признак необходимости
"""
return False
def _process_meta(self, meta: dict | None, **kwargs: Any) -> None:
"""
Разбирает переданные параметры
:param meta: Метаданные
:param kwargs: Дополнительные аргументы
"""
# Метаданные
if meta is not None:
self.meta = meta
elif kwargs:
self.meta = {}
# Дополнительные параметры
if kwargs and self.meta is not None:
self.meta.update(kwargs)

View File

@@ -0,0 +1,26 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreModel
"""
from django.db import models
from net.xeaf.rack.models.mixins import UuidIdMixin
class CoreModel(UuidIdMixin, models.Model):
"""
Базовый класс для всех классов моделей данных проекта
"""
class Meta:
"""
Параметры конфигурации класса
"""
abstract = True

View File

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

View File

@@ -0,0 +1,51 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreResponse
"""
from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
class CoreResponse(Response):
"""
Базовый класс для всех классов ответов API проекта
"""
def __init__(self, internal_status: int, external_status: int, data: list | dict | str | bool | None, meta: dict | None = None):
"""
Инициализация
:param internal_status: Внутренний код состояния
:param external_status: Внешний код состояния
:param data: Набор возвращаемых данных
:param meta: Дополнительные данные
"""
result: dict[str, list | dict | str | float | int | bool] = {
"status": internal_status,
"timestamp": int(timezone.now().timestamp())
}
# Формируем данные ответа
if data is not None:
if internal_status < status.HTTP_400_BAD_REQUEST:
result["data"] = data
else:
result["detail"] = data
# Набор дополнительных данных
if meta is not None:
result["meta"] = meta
# Формируем ответ
super().__init__(
data=result,
status=external_status
)

View File

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

View File

@@ -0,0 +1,27 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreUserStoryTestCase
"""
from django.test import SimpleTestCase
from rest_framework.test import APIRequestFactory
from .request_test_case_mixin import RequestTestCaseMixin
class CoreSimpleTestCase(SimpleTestCase, RequestTestCaseMixin):
"""
Реализует методы для тестирования без использования БД
"""
def setUp(self):
"""
Настройка перед тестированием
"""
self.factory = APIRequestFactory()

View File

@@ -0,0 +1,80 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreUserStoryTestCase
"""
from typing import Any
from django.core.management import call_command
from django.test import TestCase
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory
from net.xeaf.rack.enums import HttpMethod
from net.xeaf.rack.models.user_story import SessionTestData
from .request_test_case_mixin import RequestTestCaseMixin
class CoreUserStoryTestCase(TestCase, RequestTestCaseMixin):
"""
Реализует методы для тестирования пользовательских историй
"""
def setUp(self):
"""
Настройка перед тестированием
"""
call_command("rack_test_database")
self.factory = APIRequestFactory()
def create_ivanov_request(self, method: HttpMethod, path: str, headers: dict[str, str] | None = None,
data: dict[str, Any] | None = None, query_params: dict[str, Any] | None = None) -> Request:
"""
Создает запрос от имени Ивана Иванова
:param method: HTTP метод (GET, POST, PUT, PATCH, DELETE)
:param path: URL путь
:param headers: Заголовки запроса
:param data: Данные для тела запроса
:param query_params: Параметры запроса
:return: Объект запроса с аутентификацией Ивана Иванова
"""
return self.create_authenticated_request(
method=method,
path=path,
headers=headers,
data=data,
query_params=query_params,
token=SessionTestData.IVANOV_SESSION_TOKEN,
)
def create_petrov_request(self, method: HttpMethod, path: str, headers: dict[str, str] | None = None,
data: dict[str, Any] | None = None, query_params: dict[str, Any] | None = None) -> Request:
"""
Создает запрос от имени Петра Петрова
:param method: HTTP метод (GET, POST, PUT, PATCH, DELETE)
:param path: URL путь
:param headers: Заголовки запроса
:param data: Данные для тела запроса
:param query_params: Параметры запроса
:return: Объект запроса с аутентификацией Петра Петрова
"""
return self.create_authenticated_request(
method=method,
path=path,
headers=headers,
data=data,
query_params=query_params,
token=SessionTestData.PETROV_SESSION_TOKEN
)

View File

@@ -0,0 +1,164 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса RequestTestCaseMixin
"""
import json
from typing import Any
from rest_framework.parsers import JSONParser
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory
from net.xeaf.rack.enums import HttpMethod
from net.xeaf.rack.managers import SessionManager
from net.xeaf.rack.models import SessionModel
class RequestTestCaseMixin:
"""
Реализует методы для тестирования запросов
"""
factory: APIRequestFactory
""" Фабрика запросов """
def create_request(self,
method: HttpMethod,
path: str,
headers: dict[str, str] | None = None,
data: dict[str, Any] | None = None,
query_params: dict[str, str] | None = None,
auth: SessionModel | None = None) -> Request:
"""
Создает объект запроса для тестирования
:param method: HTTP метод (GET, POST, PUT, PATCH, DELETE)
:param path: URL путь
:param headers: Заголовки запроса
:param data: Данные для тела запроса
:param query_params: Параметры запроса
:param auth: Объект аутентификации (сессия)
:return: DRF Request объект
"""
# Уточняем значения параметров
headers = self.__prepare_headers(headers or {})
query_params = query_params or {}
data = data or {}
# Создаем внутренний объект запроса
match method:
case HttpMethod.GET:
wsgi_request = self.factory.get(path, data=query_params, **headers)
case HttpMethod.POST:
wsgi_request = self.factory.post(path, data=data, format='json', **headers)
case HttpMethod.PUT:
wsgi_request = self.factory.put(path, data=data, format='json', **headers)
case HttpMethod.PATCH:
wsgi_request = self.factory.patch(path, data=data, format='json', **headers)
case HttpMethod.DELETE:
wsgi_request = self.factory.delete(path, **headers)
case _:
wsgi_request = self.factory.generic(
method,
path,
content_type='application/json',
data=json.dumps(data) if data else ''
)
# Преобразуем к объекту запроса DRF
drf_request = Request(wsgi_request, parsers=[JSONParser()])
if method != HttpMethod.GET:
self.__assign_query_params(drf_request, query_params)
self.__assign_auth(drf_request, auth)
return drf_request
def create_authenticated_request(self,
method: HttpMethod,
path: str,
headers: dict[str, str] | None = None,
data: dict[str, Any] | None = None,
query_params: dict[str, str] | None = None,
token: str | None = None) -> Request:
"""
Создает объект авторизованного запроса для тестирования
:param method: HTTP метод (GET, POST, PUT, PATCH, DELETE)
:param path: URL путь
:param headers: Заголовки запроса
:param data: Данные для тела запроса
:param query_params: Параметры запроса
:param token: Токен сессии авторизации
:return: DRF Request объект
"""
# Получаем сессию
if token is None:
session = None
else:
session = SessionManager.find_by_token(token)
# Возвращаем объект запроса
return self.create_request(method=method, path=path, headers=headers, data=data, query_params=query_params, auth=session)
@classmethod
def __prepare_headers(cls, headers: dict[str, str]) -> dict[str, str]:
"""
Подготавливаем объект заголовков для передачи в фабрику
:param headers: Заголовки запроса
:return: Объект заголовков для фабрики
"""
wsgi_headers = {}
for key, value in headers.items():
if key.startswith('HTTP_'):
wsgi_headers[key] = value
else:
norm_key = key.upper().replace('-', '_')
if norm_key in ('CONTENT_TYPE', 'CONTENT_LENGTH'):
wsgi_headers[norm_key.lower()] = value
else:
wsgi_headers[f'HTTP_{norm_key}'] = value
return wsgi_headers
@classmethod
def __assign_query_params(cls, request: Request, query_params: dict[str, Any]) -> None:
"""
Присваивает параметры запроса в DRF Request объект
:param request: DRF Request объект
:param query_params: Параметры запроса
"""
# noinspection PyTypeChecker
request.query_params._mutable = True
for key, value in query_params.items():
request.query_params[key] = value
request.query_params._mutable = False
@classmethod
def __assign_auth(cls, request: Request, auth: SessionModel | None) -> None:
"""
Присваивает аутентификацию в DRF Request объект
:param request: Объект запроса
:param auth: Объект сессии пользователя
"""
if auth:
request.auth = auth
if hasattr(auth, 'account'):
request.user = auth.account

View File

@@ -0,0 +1,16 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет базовых классов представлений
"""
from .core_view_set import CoreViewSet
from .view_set_create_mixin import ViewSetCreateMixin
from .view_set_destroy_mixin import ViewSetDestroyMixin
from .view_set_list_mixin import ViewSetListMixin
from .view_set_retrieve_mixin import ViewSetRetrieveMixin
from .view_set_update_mixin import ViewSetUpdateMixin

View File

@@ -0,0 +1,296 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreViewSet
"""
from typing import Any
from typing import Type
from uuid import UUID
from django.core.exceptions import ImproperlyConfigured
from django.urls import path
from django.urls import URLPattern
from rest_framework.permissions import IsAuthenticated
from rest_framework.serializers import Serializer
from rest_framework.viewsets import ViewSet
from net.xeaf.rack.enums import HttpMethod
from net.xeaf.rack.enums import ViewSetAction
from net.xeaf.rack.models import AccountModel
from net.xeaf.rack.models import SessionModel
from net.xeaf.rack.utils.exceptions import BadRequestException
from net.xeaf.rack.utils.exceptions import UnauthorizedException
class CoreViewSet(ViewSet):
"""
Реализует базовый функционал для наборов представлений API
"""
permission_classes = (IsAuthenticated,)
""" Классы проверки прав доступа """
item_list_pattern: str | None = None
""" Путь для работы со списком объектов """
item_detail_pattern: str | None = None
""" Путь для работы с объектом """
response_serializer: Type[Serializer] | None = None
""" Общий класс сериализатора для ответа """
def _get_serializer(self, serializer_class: Type[Serializer] | None, default: Type[Serializer] | None) -> Type[Serializer]:
"""
Возвращает класс сериализатора
:param serializer_class: Класс сериализатора
:param default: Класс сериализатора по умолчанию
:return: Класс сериализатора
"""
if serializer_class is not None:
return serializer_class
if default is not None:
return default
raise ImproperlyConfigured(f"{self.__class__.__name__} must define 'serializer' attribute")
@classmethod
def _process_pk_param(cls, **kwargs: Any) -> UUID | None:
"""
Обработка параметра идентификатора объекта
:param kwargs: Именованные параметры
:return: Идентификатор объекта или None
"""
result = kwargs.get("pk", None)
if not result:
raise BadRequestException()
return UUID(str(result))
def _process_query_params(self, serializer: Type[Serializer] | None, **kwargs: Any) -> dict[str, Any]:
"""
Обрабатывает параметры запроса
:param serializer: Сериализатор параметров
:param kwargs: Именованные аргументы
:return: Параметры запроса
"""
# Строим набор параметров
result = {}
params = list(self.request.query_params)
for param in params:
if param != 'request':
result[param] = self.request.query_params.get(param)
# Добавляем именованные параметры
result.update(kwargs)
# Если был задан сериализатор для параметров
if serializer is not None:
srz = serializer(data=self.request.query_params, context=self._get_request_context())
srz.is_valid(raise_exception=True)
return srz.validated_data
return result
def _process_data_params(self, serializer: Type[Serializer] | None) -> dict[str, Any]:
"""
Обрабатывает параметры данных запроса
:param serializer: Сериализатор параметров
:return: Параметры запроса
"""
data = self.request.data
# Если был задан сериализатор для параметров
if serializer is not None:
partial = self._is_partial_update()
srz = serializer(data=data, context=self._get_request_context(), partial=partial)
srz.is_valid(raise_exception=True)
return srz.validated_data
return data
def _is_partial_update(self) -> bool:
"""
Проверяет, является ли текущий запрос частичным обновлением
:return: True, если запрос является частичным обновлением
"""
return self._get_request_method() == HttpMethod.PATCH
def _get_request_id(self) -> UUID:
"""
Возвращает идентификатор запроса
:return: Идентификатор запроса
"""
try:
request_id = self.request.headers.get("X-Request-ID", None)
return UUID(request_id)
except ValueError:
return UUID()
def _get_request_method(self) -> str:
"""
Возвращает имя метода HTTP протокола запроса
:return: Имя метода HTTP протокола запроса
"""
method = self.request.method
if not method:
raise ImproperlyConfigured()
return method.upper()
def _get_request_session[T: SessionModel](self) -> T | None:
"""
Возвращает сессию, связанную с текущим запросом
:return: Сессия, связанная с текущим запросом
"""
# noinspection PyTypeChecker
session: SessionModel = self.request.auth
return session
def _get_request_account[T: AccountModel](self) -> T:
"""
Возвращает аккаунт, связанный с текущим запросом
:return: Аккаунт, связанный с текущим запросом
"""
session = self._get_request_session()
if session is not None:
# noinspection PyTypeChecker
account: T | None = session.account
if account is not None:
return account
raise UnauthorizedException()
def _get_request_context(self) -> dict[str, Any]:
"""
Возвращает контекст запроса
:return: Объект контекста запроса
"""
try:
account = self._get_request_account()
except UnauthorizedException:
account = None
return {
"account": account,
"request": self.request
}
@classmethod
def get_paths(cls) -> list[URLPattern]:
"""
Возвращает полный список путей набора представлений
:return: Список путей
"""
return cls._item_list_paths() + cls._item_detail_paths()
@classmethod
def _path_method(cls, action: ViewSetAction) -> str:
"""
Возвращает имя метода для реализации действия представления
:param action: Действие
:return: Имя метода
"""
return ViewSetAction.get_method(action)
@classmethod
def _path_method_exists(cls, action: ViewSetAction) -> bool:
"""
Проверяет наличие метода для реализации действия представления
:param action: Действие
:return: True, если метод найден, иначе False
"""
method_name = cls._path_method(action)
if hasattr(cls, method_name):
attr = getattr(cls, method_name)
if callable(attr):
return True
return False
@classmethod
def _item_list_paths(cls) -> list[URLPattern]:
"""
Возвращает список путей для работы со списком объектов
:return: Список путей
"""
if cls.item_list_pattern is None:
return []
args = {}
if cls._path_method_exists(ViewSetAction.LIST):
args["get"] = cls._path_method(ViewSetAction.LIST)
if cls._path_method_exists(ViewSetAction.CREATE):
args["post"] = cls._path_method(ViewSetAction.CREATE)
# Если есть путь, но нет методов
if not args:
raise ImproperlyConfigured("No methods defined for item_list_pattern.")
return [path(cls.item_list_pattern, cls.as_view(args))]
@classmethod
def _item_detail_paths(cls) -> list[URLPattern]:
"""
Возвращает список путей для работы с объектом
:return: Список путей
"""
if cls.item_detail_pattern is None:
return []
args = {}
if cls._path_method_exists(ViewSetAction.RETRIEVE):
args["get"] = cls._path_method(ViewSetAction.RETRIEVE)
if cls._path_method_exists(ViewSetAction.UPDATE):
args["put"] = cls._path_method(ViewSetAction.UPDATE)
if cls._path_method_exists(ViewSetAction.PARTIAL_UPDATE):
args["patch"] = cls._path_method(ViewSetAction.PARTIAL_UPDATE)
if cls._path_method_exists(ViewSetAction.DESTROY):
args["delete"] = cls._path_method(ViewSetAction.DESTROY)
# Если есть путь, но нет методов
if not args:
raise ImproperlyConfigured("No methods defined for item_detail_pattern.")
return [path(cls.item_detail_pattern, cls.as_view(args))]

View File

@@ -0,0 +1,92 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ViewSetCreateMixin
"""
from typing import Any
from typing import Type
from django.db import transaction
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.request import Request
from rest_framework.serializers import Serializer
from net.xeaf.rack.core import CoreModel
from net.xeaf.rack.core import CoreResponse
from net.xeaf.rack.enums import HttpMethod
from net.xeaf.rack.models.responses import DataResponse
from .core_view_set import CoreViewSet
class ViewSetCreateMixin(CoreViewSet):
"""
Реализует базовый функционал для создания объекта
"""
create_param_serializer: Type[Serializer] | None = None
""" Класс сериализатора для параметров запроса на создание объекта """
create_data_serializer: Type[Serializer] | None = None
""" Класс сериализатора для данных запроса на создание объекта """
create_response_serializer: Type[Serializer] | None = None
""" Класс сериализатора для ответа на запрос о создании объекта """
def item_create(self, _request: Request, **kwargs: dict[str, Any]) -> CoreResponse:
"""
Обрабатывает HTTP запрос и возвращает ответ с новым объектом
:param _request: Пропускаемый параметр запроса
:param kwargs: Именованные аргументы
:return: Ответ
"""
params = self._process_create_params(**kwargs)
with transaction.atomic():
result = self._process_create(params)
return self._process_create_response(result)
def _process_create(self, params: dict[str, Any]) -> dict[str, Any] | CoreModel:
"""
Обрабатывает создание объекта для формирования ответа
:param params: Параметры запроса
:return: Выбранный объект
"""
raise MethodNotAllowed(method=HttpMethod.POST)
def _process_create_params(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
"""
Разбирает и проверяет параметры запроса списка объектов
:param kwargs: Именованные аргументы
:return: Список параметров
"""
query_params = self._process_query_params(self.create_param_serializer, **kwargs)
data_params = self._process_data_params(self.create_data_serializer)
return {**query_params, **data_params}
def _process_create_response(self, data: dict[str, Any] | CoreModel) -> CoreResponse:
"""
Обрабатывает список объектов для формирования ответа
:param data: Список объектов
:return: Ответ для отправки
"""
srz_class = self._get_serializer(self.create_response_serializer, self.response_serializer)
srz = srz_class(data, many=False, context=self._get_request_context())
return DataResponse(data=srz.data)

View File

@@ -0,0 +1,95 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ViewSetDestroyMixin
"""
import uuid
from typing import Any
from typing import Type
from django.db import transaction
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.request import Request
from rest_framework.serializers import Serializer
from net.xeaf.rack.core import CoreModel
from net.xeaf.rack.core import CoreResponse
from net.xeaf.rack.enums import HttpMethod
from net.xeaf.rack.models.responses import DataResponse
from net.xeaf.rack.models.responses import EmptyResponse
from .core_view_set import CoreViewSet
class ViewSetDestroyMixin(CoreViewSet):
"""
Реализует базовый функционал представления для удаления объекта
"""
destroy_param_serializer: Type[Serializer] | None = None
""" Класс сериализатора для параметров запроса объекта """
destroy_response_serializer: Type[Serializer] | None = None
""" Класс сериализатора для ответа на запрос объекта """
def item_destroy(self, _request: Request, **kwargs: dict[str, Any]) -> CoreResponse:
"""
Обрабатывает HTTP запрос и возвращает ответ с объектом
:param _request: Пропускаемый параметр запроса
:param kwargs: Именованные аргументы
:return: Ответ
"""
pk = self._process_pk_param(**kwargs)
params = self._process_destroy_params(**kwargs)
with transaction.atomic():
result = self._process_destroy(pk, params)
return self._process_destroy_response(result)
def _process_destroy(self, pk: uuid.UUID | None, params: dict[str, Any]) -> dict[str, Any] | CoreModel | None:
"""
Обрабатывает удаление объекта
:param pk: Идентификатор объекта
:param params: Параметры запроса
:return: Выбранный объект
"""
raise MethodNotAllowed(method=HttpMethod.DELETE)
def _process_destroy_params(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
"""
Разбирает и проверяет параметры запроса объекта
:param kwargs: Именованные аргументы
:return: Список параметров
"""
return self._process_query_params(self.destroy_param_serializer, **kwargs)
def _process_destroy_response(self, data: dict[str, Any] | CoreModel | None) -> CoreResponse:
"""
Обрабатывает объект для формирования ответа
:param data: Список объектов
:return: Ответ для отправки
"""
# Частный случай пустого ответа
if data is None:
return EmptyResponse()
srz_class = self._get_serializer(self.destroy_response_serializer, self.response_serializer)
srz = srz_class(data, many=False, context=self._get_request_context())
return DataResponse(data=srz.data)

View File

@@ -0,0 +1,85 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ViewSetListMixin
"""
from typing import Any
from typing import Type
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.request import Request
from rest_framework.serializers import Serializer
from net.xeaf.rack.core import CoreModel
from net.xeaf.rack.core import CoreResponse
from net.xeaf.rack.enums import HttpMethod
from net.xeaf.rack.models.responses import ListResponse
from .core_view_set import CoreViewSet
class ViewSetListMixin(CoreViewSet):
"""
Реализует базовый функционал для выбора списков
"""
list_param_serializer: Type[Serializer] | None = None
""" Класс сериализатора для параметров запроса списка объектов """
list_response_serializer: Type[Serializer] | None = None
""" Класс сериализатора для ответа на запрос списка объектов """
def item_list(self, _request: Request, **kwargs: dict[str, Any]) -> CoreResponse:
"""
Обрабатывает HTTP запрос и возвращает ответ со списком объектов
:param _request: Пропускаемый параметр запроса
:param kwargs: Именованные аргументы
:return: Ответ
"""
params = self._process_list_params(**kwargs)
result = self._process_list(params)
return self._process_list_response(result)
def _process_list(self, params: dict[str, Any]) -> list[dict[str, Any] | CoreModel]:
"""
Обрабатывает выбор списка объектов для формирования ответа
:param params: Параметры запроса
:return: Выбранный объект
"""
raise MethodNotAllowed(method=HttpMethod.GET)
def _process_list_params(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
"""
Разбирает и проверяет параметры запроса списка объектов
:param kwargs: Именованные аргументы
:return: Список параметров
"""
return self._process_query_params(self.list_param_serializer, **kwargs)
def _process_list_response(self, data: list[dict[str, Any] | CoreModel]) -> CoreResponse:
"""
Обрабатывает список объектов для формирования ответа
:param data: Список объектов
:return: Ответ для отправки
"""
srz_class = self._get_serializer(self.list_response_serializer, self.response_serializer)
srz = srz_class(data, many=True, context=self._get_request_context())
return ListResponse(data=list(srz.data))

View File

@@ -0,0 +1,88 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ViewSetUpdateMixin
"""
import uuid
from typing import Any
from typing import Type
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.request import Request
from rest_framework.serializers import Serializer
from net.xeaf.rack.core import CoreModel
from net.xeaf.rack.core import CoreResponse
from net.xeaf.rack.enums import HttpMethod
from net.xeaf.rack.models.responses import DataResponse
from .core_view_set import CoreViewSet
class ViewSetRetrieveMixin(CoreViewSet):
"""
Реализует базовый функционал представления для выбора объекта
"""
retrieve_param_serializer: Type[Serializer] | None = None
""" Класс сериализатора для параметров запроса объекта """
retrieve_response_serializer: Type[Serializer] | None = None
""" Класс сериализатора для ответа на запрос объекта """
def item_retrieve(self, _request: Request, **kwargs: dict[str, Any]) -> CoreResponse:
"""
Обрабатывает HTTP запрос и возвращает ответ с объектом
:param _request: Пропускаемый параметр запроса
:param kwargs: Именованные аргументы
:return: Ответ
"""
pk = self._process_pk_param(**kwargs)
params = self._process_retrieve_params(**kwargs)
result = self._process_retrieve(pk, params)
return self._process_retrieve_response(result)
def _process_retrieve(self, pk: uuid.UUID | None, params: dict[str, Any]) -> dict[str, Any] | CoreModel:
"""
Обрабатывает выбор объекта для формирования ответа
:param pk: Идентификатор объекта
:param params: Параметры запроса
:return: Выбранный объект
"""
raise MethodNotAllowed(method=HttpMethod.GET)
def _process_retrieve_params(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
"""
Разбирает и проверяет параметры запроса объекта
:param kwargs: Именованные аргументы
:return: Список параметров
"""
return self._process_query_params(self.retrieve_param_serializer, **kwargs)
def _process_retrieve_response(self, data: dict[str, Any] | CoreModel) -> CoreResponse:
"""
Обрабатывает объект для формирования ответа
:param data: Список объектов
:return: Ответ для отправки
"""
srz_class = self._get_serializer(self.retrieve_response_serializer, self.response_serializer)
srz = srz_class(data, many=False, context=self._get_request_context())
return DataResponse(data=srz.data)

View File

@@ -0,0 +1,107 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ViewSetUpdateMixin
"""
import uuid
from typing import Any
from typing import Type
from django.db import transaction
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.request import Request
from rest_framework.serializers import Serializer
from net.xeaf.rack.core import CoreModel
from net.xeaf.rack.core import CoreResponse
from net.xeaf.rack.models.responses import DataResponse
from .core_view_set import CoreViewSet
class ViewSetUpdateMixin(CoreViewSet):
"""
Реализует базовый функционал представления для изменения объекта
"""
update_param_serializer: Type[Serializer] | None = None
""" Класс сериализатора для параметров запроса на изменение объекта """
update_data_serializer: Type[Serializer] | None = None
""" Класс сериализатора для данных запроса на изменение объекта """
update_response_serializer: Type[Serializer] | None = None
""" Класс сериализатора для ответа на запрос на изменение объекта """
def item_update(self, _request: Request, **kwargs: dict[str, Any]) -> CoreResponse:
"""
Обрабатывает HTTP запрос и возвращает ответ с объектом
:param _request: Пропускаемый параметр запроса
:param kwargs: Именованные аргументы
:return: Ответ
"""
pk = self._process_pk_param(**kwargs)
params = self._process_update_params(**kwargs)
with transaction.atomic():
result = self._process_update(pk, params)
return self._process_update_response(result)
def item_partial_update(self, request: Request, **kwargs: dict[str, Any]) -> CoreResponse:
"""
Обрабатывает HTTP запрос и возвращает ответ с измененным объектом
:param request: Запрос
:param kwargs: Именованные аргументы
:return: Ответ
"""
return self.item_update(request, **kwargs)
def _process_update(self, pk: uuid.UUID | None, params: dict[str, Any]) -> dict[str, Any] | CoreModel:
"""
Обрабатывает выбор и изменение объекта для формирования ответа
:param pk: Идентификатор объекта
:param params: Параметры запроса
:return: Выбранный объект
"""
raise MethodNotAllowed(method=self._get_request_method())
def _process_update_params(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
"""
Разбирает и проверяет параметры запроса объекта
:param kwargs: Именованные аргументы
:return: Список параметров
"""
query_params = self._process_query_params(self.update_param_serializer, **kwargs)
data_params = self._process_data_params(self.update_data_serializer)
return {**query_params, **data_params}
def _process_update_response(self, data: dict[str, Any] | CoreModel) -> CoreResponse:
"""
Обрабатывает объект для формирования ответа
:param data: Список объектов
:return: Ответ для отправки
"""
srz_class = self._get_serializer(self.update_response_serializer, self.response_serializer)
srz = srz_class(data, many=False, context=self._get_request_context())
return DataResponse(data=srz.data)

View File

@@ -0,0 +1,14 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет описания классов перечислений
"""
from .auth_reject_reson import AuthRejectReason
from .http_method import HttpMethod
from .ui_language import UILanguage
from .view_set_action import ViewSetAction

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

@@ -0,0 +1,42 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса HttpMethod
"""
from enum import unique
from net.xeaf.rack.core import CoreEnum
@unique
class HttpMethod(CoreEnum):
"""
Перечисление методов протокола HTTP
"""
GET = 'GET'
""" Получение данных """
POST = 'POST'
""" Создание данных """
PUT = 'PUT'
""" Изменение данных """
PATCH = 'PATCH'
""" Частичное изменение данных """
DELETE = 'DELETE'
""" Удаление данных """
HEAD = 'HEAD'
""" Получение заголовков """
OPTIONS = 'OPTIONS'
""" Получение возможных действий """

View File

@@ -0,0 +1,39 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса UILanguage
"""
from net.xeaf.rack.core import CoreEnum
class UILanguage(CoreEnum):
"""
Перечисление доступных языков интерфейса
"""
ENGLISH = 'en'
""" Английский """
RUSSIAN = 'ru'
""" Русский """
@classmethod
def from_string(cls, value: str) -> UILanguage:
"""
Создает значение из строкового представления
:param value: Строковое значение
:return: Язык интерфейса пользователя
"""
normalized = value.lower().split('-')[0]
for member in cls:
if member.value == normalized:
return member
raise ValueError(f"Unsupported language: {value}")

View File

@@ -0,0 +1,49 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ViewSetAction
"""
from enum import unique
from net.xeaf.rack.core import CoreEnum
@unique
class ViewSetAction(CoreEnum):
"""
Перечисление действий набора представлений
"""
LIST = "LIST"
""" Список объектов """
RETRIEVE = "RETRIEVE"
""" Получение объекта """
CREATE = "CREATE"
""" Создание объекта """
UPDATE = "UPDATE"
""" Изменение объекта """
PARTIAL_UPDATE = "PARTIAL_UPDATE"
""" Частичное изменение объекта """
DESTROY = "DESTROY"
""" Удаление объекта """
@classmethod
def get_method(cls, action: ViewSetAction) -> str:
"""
Возвращает идентификатор метода класса для указанного действия
:param action: Действие
:return: Идентификатор метода класса
"""
return f"item_{action.value.lower()}"

View File

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

View File

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

View File

@@ -0,0 +1,405 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса для команды rack_coverage
"""
import ast
import configparser
import fnmatch
import importlib
import os
import re
import sys
from pathlib import Path
from django.conf import settings
from django.core.management import CommandError
from net.xeaf.rack.core import CoreCommand
from net.xeaf.rack.utils.management import CoverageStructureVisitor
from net.xeaf.rack.utils.management import CoverageTestFileVisitor
class Command(CoreCommand):
"""
Проверяет соблюдение принципа один метод - один файл тестов
"""
# noinspection SpellCheckingInspection
CONFIG_FILE = '.rackcoveragerc'
""" Имя файла конфигурации """
CONFIG_SECTION = "rack-coverage"
""" Имя секции в файле конфигурации """
help = "Discover test coverage in the specified modules or the current directory."
""" Подсказка """
success_count: int = 0
""" Счетчик успешно проверенных методов """
missing_files_count: int = 0
""" Счетчик не найденных файлов с тестами """
missing_classes_count: int = 0
""" Счетчик не найденных классов с тестами """
skipped_packages_count: int = 0
""" Счетчик пропущенных пакетов """
test_files_map: dict[str, Path] = {}
""" Карта файлов тестов """
def add_arguments(self, parser):
"""
Позиционный аргумент: путь к пакету от корня
:param parser: Параметры командной строки
"""
parser.add_argument(
"package_path",
type=str,
nargs="?",
help="Module paths to check; can be modulename, modulename.TestCase or modulename.TestCase.test_method"
)
def handle(self, *args, **options):
"""
Обработка команды построения покрытия тестами
"""
# Конфигурация
config = self._load_config()
target_package = options["package_path"]
packages_to_check = [target_package] if target_package else config.get("packages", [])
exclude_patterns = config.get("exclude", [])
include_patterns = config.get("include", [])
if not packages_to_check:
raise CommandError("No package specified for verification.")
for package_name in packages_to_check:
package_dir: Path | None = self._find_package_dir(package_name)
if not package_dir:
self.stdout.write(self.style.WARNING(f"[F] Package '{package_name}' skipped: its directory was not found."))
self.skipped_packages_count += 1
continue
tests_info: tuple[str, Path] | None = self._autodiscover_tests_package(package_name)
if not tests_info:
self.stdout.write(self.style.ERROR(f"[F] Could not find a test package for: {package_name}."))
self.skipped_packages_count += 1
continue
tests_root_name, tests_root_dir = tests_info
self._index_test_files(tests_root_dir)
# Обходим файлы боевого кода
for root, _, files in os.walk(package_dir):
current_path: Path = Path(root)
# Защита от зацикливания
if "tests" in current_path.parts:
continue
for file in files:
# Только файлы python и не файлы пакетов
if not file.endswith(".py") or file == "__init__.py":
continue
source_file: Path = current_path / file
relative_to_package: Path = source_file.relative_to(package_dir)
module_parts: tuple[str, ...] = relative_to_package.with_suffix("").parts
module_dot_path: str = f"{package_name}.{'.'.join(module_parts)}"
extracted_items: list[tuple[str, str | None, str]] = self._extract_classes_and_methods(
source_file, module_dot_path
)
for method_name, class_name, full_path in extracted_items:
# Фильтрация: include / exclude
is_included: bool = any(fnmatch.fnmatch(full_path, p) for p in include_patterns)
if not is_included:
if any(fnmatch.fnmatch(full_path, p) for p in exclude_patterns):
continue
# Получаем путь к файлу с тестами
test_file_path, target_file_name = self._get_test_file_path(class_name, method_name)
if not test_file_path:
self.stdout.write(self.style.ERROR(f"[E] Method: {full_path}"))
self.stdout.write(self.style.ERROR(f" Test file {target_file_name} is missing."))
self.missing_files_count += 1
continue
expected_class_name = self._get_expected_class_name(class_name, method_name)
self._parse_tests_file(test_file_path, expected_class_name, full_path)
# Вывод финального отчёта
self._final_report()
def _final_report(self) -> None:
"""
Выводит финальный отчёт в терминал
"""
total_errors: int = self.missing_files_count + self.missing_classes_count + self.skipped_packages_count
self._report_separator(first_new_line=True)
self.stdout.write(self.style.SUCCESS(f"Test coverage report:"))
self._report_separator(double=False)
self._report_int_value("Success", self.success_count)
if self.skipped_packages_count > 0:
self._report_int_value("Skipped packages", self.skipped_packages_count)
if self.missing_files_count > 0:
self._report_int_value("Missing test files", self.missing_files_count)
if self.missing_classes_count > 0:
self._report_int_value("Missing test classes", self.missing_classes_count)
self._report_separator(double=False, last_new_line=False)
if total_errors > 0:
self._report_int_value("FAIL! Total number of errors", total_errors)
self._report_separator(double=False, last_new_line=False)
sys.exit(1)
else:
self._report_int_value("SUCCESS! Total number of items", self.success_count)
self._report_separator(double=False, last_new_line=False)
sys.exit(0)
def _get_test_file_path(self, class_name: str | None, method_name: str) -> tuple[Path | None, str]:
"""
Возвращает путь к тестовому файлу для указанного класса и метода.
:param class_name: Имя класса
:param method_name: Имя метода
:return: Путь к тестовому файлу и имя файла
"""
if class_name:
if method_name == "__init__":
target_file_name: str = f"{self._camel_to_snake(class_name)}_tests.py"
else:
target_file_name = (
f"{self._camel_to_snake(class_name)}_{self._camel_to_snake(method_name)}_tests.py"
)
else:
target_file_name = f"{self._camel_to_snake(method_name)}_tests.py"
return self.test_files_map.get(target_file_name), target_file_name
@classmethod
def _get_expected_class_name(cls, class_name: str | None, method_name: str) -> str:
"""
Получает ожидаемое имя класса
:param class_name: Имя класса
:param method_name: Имя метода
"""
base_class: str = class_name if class_name else ""
if method_name == "__init__":
return f"{base_class}Tests"
else:
camel_method: str = "".join(
word.capitalize() for word in method_name.lstrip("_").split("_")
)
return f"{base_class}{camel_method}Tests"
def _parse_tests_file(self, test_file_path: Path, expected_class_name: str, full_path: str) -> None:
"""
Парсит файлы тестов
:param test_file_path: Путь к файлу тестов
:param expected_class_name: Ожидаемое имя класса
:param full_path: Полный путь к методу
"""
try:
test_node: ast.Module = ast.parse(test_file_path.read_text(encoding="utf-8"))
test_visitor: CoverageTestFileVisitor = CoverageTestFileVisitor()
test_visitor.visit(test_node)
test_classes_in_file: set[str] = test_visitor.test_classes
except (SyntaxError, UnicodeDecodeError):
test_classes_in_file = set()
if expected_class_name not in test_classes_in_file:
self.stdout.write(self.style.ERROR(f"[E] The file {test_file_path.relative_to(settings.BASE_DIR)}"))
self.stdout.write(self.style.ERROR(f" is missing the class {expected_class_name}"))
self.stdout.write(self.style.ERROR(f" for the method {full_path}."))
self.missing_classes_count += 1
else:
self.success_count += 1
@classmethod
def _index_test_files(cls, tests_root_dir: Path) -> None:
"""
Индексирует файлы тестов в автоматически найденном корне для быстрого поиска
:param tests_root_dir: Корневая директория для поиска файлов тестов
"""
cls.test_files_map = {}
for root, _, files in os.walk(tests_root_dir):
for file in files:
if file.endswith(".py") and file != "__init__.py":
cls.test_files_map[file] = Path(root) / file
@classmethod
def _extract_classes_and_methods(cls, file_path: Path, module_dot_path: str) -> list[tuple[str, str | None, str]]:
"""
Разбирает файл через AST, используя CoverageStructureVisitor
:param file_path: Путь к файлу
:param module_dot_path: Путь к модулю
:return: Список кортежей с именами классов, методов и именами файлов
"""
try:
node = ast.parse(file_path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
return []
visitor = CoverageStructureVisitor(module_dot_path)
visitor.visit(node)
return visitor.results
@classmethod
def _extract_test_classes(cls, test_file_path: Path) -> set[str]:
"""
Парсит файл тестов и возвращает имена всех объявленных в нем классов
:param test_file_path: Путь к файлу с тестами
:return: Множество имен классов
"""
try:
node = ast.parse(test_file_path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError):
return set()
visitor = CoverageTestFileVisitor()
visitor.visit(node)
return visitor.test_classes
def _autodiscover_tests_package(self, package_name: str) -> tuple[str, Path] | None:
"""
Умный алгоритм автоматического обнаружения тестовых пакетов
:param package_name: Имя пакета
:return: Кортеж из имени пакета тестов и пути к нему, или None, если не найден
"""
parts = package_name.split(".")
# Проходим циклом с конца имени пакета вверх до корня
for i in range(len(parts), 0, -1):
base_parts = parts[:i]
suffix_parts = parts[i:]
# Собираем гипотетическое имя пакета тестов
candidate_parts = base_parts + ["tests"] + suffix_parts
candidate_name = ".".join(candidate_parts)
# Проверяем, существует ли такой пакет физически на диске
candidate_dir = self._find_package_dir(candidate_name)
if candidate_dir:
return candidate_name, candidate_dir
return None
@classmethod
def _camel_to_snake(cls, name: str) -> str:
"""
Переводит CamelCase в snake_case
:param name: Имя в CamelCase
:return: Имя в snake_case
"""
clean_name = name.lstrip("_")
clean_name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", clean_name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", clean_name).lower()
@classmethod
def _snake_to_camel(cls, name: str) -> str:
"""
Переводит snake_case в CamelCase
:param name: Имя в snake_case
:return: Имя в CamelCase
"""
clean_name = name.lstrip("_")
return "".join(word.capitalize() for word in clean_name.split("_"))
@classmethod
def _find_package_dir(cls, package_name: str) -> Path | None:
"""
Возвращает директорию пакета
:param package_name: Имя пакета
:return: Путь к директории пакета или None, если пакет не найден
"""
try:
module = importlib.import_module(package_name)
if hasattr(module, "__path__"):
paths = list(module.__path__)
if paths:
return Path(paths[0])
except (ModuleNotFoundError, ImportError, ValueError):
pass
return None
def _load_config(self) -> dict:
"""
Загружает файл конфигурации
:return: Словарь с настройками
"""
rc_path = Path(settings.BASE_DIR) / self.CONFIG_FILE
if not rc_path.exists():
return {}
config = configparser.ConfigParser()
try:
config.read(rc_path, encoding="utf-8")
if not config.has_section("rack-coverage"):
return {}
raw_packages = config.get("rack-coverage", "packages", fallback="")
raw_exclude = config.get("rack-coverage", "exclude", fallback="")
raw_include = config.get("rack-coverage", "include", fallback="")
# Чистим строки от пробелов и переносов, превращая в списки
packages_list = [p.strip() for p in raw_packages.splitlines() if p.strip()]
exclude_list = [e.strip() for e in raw_exclude.splitlines() if e.strip()]
include_list = [i.strip() for i in raw_include.splitlines() if i.strip()]
return {
"packages": packages_list,
"exclude": exclude_list,
"include": include_list
}
except configparser.Error as err:
self.stdout.write(self.style.ERROR(f"Error reading configuration file {self.CONFIG_FILE}: {err}"))
return {}

View File

@@ -0,0 +1,44 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса для команды rack_test_database
"""
from django.core.management import call_command
from django.core.management.base import no_translations
from net.xeaf.rack.core import CoreCommand
from net.xeaf.rack.models.user_story import AccountTestData
from net.xeaf.rack.models.user_story import SessionTestData
class Command(CoreCommand):
"""
Инициализирует базу данных для тестирования
"""
@no_translations
def handle(self, *args, **options):
"""
Обрабатывает команду инициализации БД
:params args: Позиционные аргументы
:params options: Дополнительные параметры
"""
if self._is_debug_or_test():
# Готовимся
self._clear_database()
call_command("migrate")
# Инициализируем данные
AccountTestData.init_accounts()
SessionTestData.init_sessions()
else:
self.stdout.write(self.style.ERROR("The rack_test_database command can only be executed in debug or testing mode."))

View File

@@ -0,0 +1,33 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса для команды rack_wipe_database
"""
from django.core.management.base import no_translations
from net.xeaf.rack.core import CoreCommand
class Command(CoreCommand):
"""
Очищает базу данных
"""
@no_translations
def handle(self, *args, **options):
"""
Обрабатывает команду инициализации БД
:params args: Позиционные аргументы
:params options: Дополнительные параметры
"""
if self._is_debug_or_test():
self._clear_database()
else:
self.stdout.write(self.style.ERROR("The rack_wipe_database command can only be executed in debug or test mode."))

View File

@@ -0,0 +1,15 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет классов менеджеров
"""
from .account_manager import AccountManager
from .crypto_manager import CryptoManager
from .locale_manager import LocaleManager
from .request_manager import RequestManager
from .session_manager import SessionManager

View File

@@ -0,0 +1,68 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса AccountManager
"""
from net.xeaf.rack.enums import AuthRejectReason
from net.xeaf.rack.models.account_model import AccountModel
class AccountManager:
"""
Реализует методы работы с Аккаунтами
"""
@classmethod
def check_account_auth(cls, account: AccountModel | None) -> AuthRejectReason | None:
"""
Проверяет возможность авторизации Аккаунта
:param account: Аккаунт
:return: Причина отказа в авторизации или None, если авторизация возможна
"""
if account is None:
return AuthRejectReason.INVALID_USERNAME_OR_PASSWORD
if not account.is_active:
return AuthRejectReason.ACCOUNT_IS_NOT_ACTIVE
if account.joined_at is None:
return AuthRejectReason.REGISTRATION_IS_NOT_COMPLETE
return None
@classmethod
def check_username_auth(cls, username: str) -> AuthRejectReason | None:
"""
Проверяет возможность авторизации Аккаунта с заданным именем
:param username: Имя Аккаунта
:return: Причина отказа в авторизации или None, если авторизация возможна
"""
account = cls.find_by_username(username)
return cls.check_account_auth(account)
@classmethod
def find_by_username(cls, username: str) -> AccountModel | None:
"""
Возвращает Аккаунт с указанным именем
:param username: Имя Аккаунта
:return: Аккаунт или None, если Аккаунт не найден
"""
return AccountModel.objects.filter(username=username).first()
@classmethod
def restore_account(cls, account: AccountModel) -> None:
"""
Восстанавливает удаленный Аккаунт
"""
if account.is_deleted():
account.undelete(save=True)

View File

@@ -0,0 +1,76 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CryptoManager
"""
import secrets
from net.xeaf.rack.models import SessionModel
class CryptoManager:
"""
Реализует методы работы со случайными и криптографическими значениями
"""
DEFAULT_TOKEN_LENGTH = 40
""" Длина токена по умолчанию """
DEFAULT_PIN_CODE_LENGTH = 4
""" Длина PIN-кода по умолчанию """
DEFAULT_PIN_CODE_ALPHABET = "0123456789"
""" Алфавит PIN-кода по умолчанию """
DEFAULT_SLUG_LENGTH = 8
""" Длина slug по умолчанию """
@classmethod
def gen_token(cls, length: int = DEFAULT_TOKEN_LENGTH) -> str:
"""
Генерирует случайный токен
:param length: Длина токена
:return: Сгенерированный токен
"""
# Ограничение длины токена
if length > SessionModel.MAX_TOKEN_LENGTH:
raise ValueError(f"Token length must be less than {SessionModel.MAX_TOKEN_LENGTH}")
bytes_needed = (length + 1) // 2
token = secrets.token_hex(bytes_needed)
return token[:length]
@classmethod
def gen_pin_code(cls, length: int = DEFAULT_PIN_CODE_LENGTH, alphabet: str = DEFAULT_PIN_CODE_ALPHABET):
"""
Генерирует случайный PIN-код
:param length: Длина PIN-кода
:param alphabet: Алфавит PIN-кода
:return: Сгенерированный PIN-код
"""
return "".join(secrets.choice(alphabet) for _ in range(length))
@classmethod
def gen_slug(cls, prefix: str, length: int = DEFAULT_SLUG_LENGTH):
"""
Генерирует случайный slug
:param prefix: Префикс slug
:param length: Длина slug
:return: Сгенерированный slug
"""
l = length - len(prefix)
if l <= 0:
return prefix
return prefix + cls.gen_token(l)

View File

@@ -0,0 +1,126 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса LocaleManager
"""
from django.conf import settings
from django.utils import translation
from rest_framework.request import Request
from net.xeaf.rack.enums import UILanguage
class LocaleManager:
"""
Реализует методы работы с локализацией
"""
UI_LANGUAGE_PARAM = "x-ui-language"
""" Идентификатор параметра языка интерфейса """
UI_LANGUAGE_META = "HTTP_ACCEPT_LANGUAGE"
""" Идентификатор заголовка языка интерфейса """
@classmethod
def get_default_language(cls) -> UILanguage:
"""
Возвращает языка интерфейса пользователя по умолчанию
:return: Язык интерфейса пользователя по умолчанию
"""
return UILanguage.from_string(settings.LANGUAGE_CODE)
@classmethod
def get_accepted_language(cls, language_code: str) -> UILanguage:
"""
Проверяет и возвращает допустимый код языка
:param language_code: Код языка интерфейса пользователя
:return: Допустимый код языка интерфейса пользователя
"""
try:
return UILanguage.from_string(language_code)
except ValueError:
return cls.get_default_language()
@classmethod
def get_request_language(cls, request: Request | None) -> UILanguage:
"""
Возвращает идентификатор языка из запроса
:param request: Информация о запросе
:return: Язык интерфейса пользователя из запроса
"""
if not request:
return cls.get_default_language()
if request.method == "GET":
language_code = request.query_params.get(cls.UI_LANGUAGE_PARAM, None)
if language_code:
return cls.get_accepted_language(language_code=language_code)
try:
language_code = cls.process_accepted_language(request)
return cls.get_accepted_language(language_code=language_code)
except KeyError:
return cls.get_default_language()
@classmethod
def set_request_language(cls, request: Request | None, ui_language: UILanguage) -> None:
"""
Задает язык интерфейса пользователя для запроса
:param request: Информация о запросе
:param ui_language: Язык интерфейса пользователя
"""
translation.activate(str(ui_language).lower())
if request:
request.LANGUAGE_CODE = str(translation.get_language()).upper()
@classmethod
def process_accepted_language(cls, request: Request) -> str:
"""
Обрабатывает значение META_ACCEPTED_LANGUAGE
:param request: Информация о запросе
:return: Код языка интерфейса пользователя из запроса
"""
index = -1
result = cls.get_default_language()
language = request.META[cls.UI_LANGUAGE_META]
for ui_lang in UILanguage.values():
lang = ui_lang.lower()
try:
idx = language.index(lang)
if index == -1 or idx < index:
index = idx
result = lang
except ValueError:
pass
return result
@classmethod
def adjust_request_language(cls, request: Request) -> None:
"""
Настраивает язык интерфейса пользователя
:param request: Информация о запросе
"""
language = cls.get_request_language(request)
cls.set_request_language(request, language)

View File

@@ -0,0 +1,120 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса RequestManager
"""
import uuid
from typing import Any
from uuid import UUID
from django.core.exceptions import ImproperlyConfigured
from rest_framework.request import Request
from net.xeaf.rack.models import AccountModel
from net.xeaf.rack.models import SessionModel
from net.xeaf.rack.utils.exceptions import BadRequestException
from net.xeaf.rack.utils.exceptions import UnauthorizedException
class RequestManager:
"""
Реализует методы работы с запросами
"""
REQUEST_ID_META = "X-Request-ID"
""" Идентификатор запроса """
@classmethod
def get_request_id(cls, request: Request) -> UUID:
"""
Возвращает идентификатор запроса
:param request: Объект запроса
:return: Идентификатор запроса
"""
request_id = request.headers.get(cls.REQUEST_ID_META, None)
if request_id is None:
return uuid.uuid4()
try:
return UUID(str(request_id))
except ValueError:
raise BadRequestException()
@classmethod
def get_request_method(cls, request: Request) -> str:
"""
Возвращает имя метода HTTP протокола запроса
:param request: Объект запроса
:return: Имя метода HTTP протокола запроса
"""
method = request.method
if not method:
raise ImproperlyConfigured()
return method.upper()
@classmethod
def get_request_session[T: SessionModel](cls, request: Request) -> T | None:
"""
Возвращает сессию, связанную с текущим запросом
:param request: Объект запроса
:return: Сессия, связанная с текущим запросом
"""
# noinspection PyTypeChecker
session: SessionModel | None = request.auth
return session
@classmethod
def get_request_account[T: AccountModel](cls, request: Request) -> T:
"""
Возвращает аккаунт, связанный с текущим запросом
:param request: Объект запроса
:return: Аккаунт, связанный с текущим запросом
"""
session = cls.get_request_session(request)
if session is not None:
# noinspection PyTypeChecker
account: T | None = session.account
if account is not None:
return account
raise UnauthorizedException()
@classmethod
def get_request_context(cls, request: Request) -> dict[str, Any]:
"""
Возвращает контекст запроса
:param request: Объект запроса
:return: Объект контекста запроса
"""
try:
account = cls.get_request_account(request)
except UnauthorizedException:
account = None
return {
"id": cls.get_request_id(request),
"account": account,
"request": request
}

View File

@@ -0,0 +1,177 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса SessionManager
"""
from typing import NoReturn
from typing import Type
from django.apps import apps
from django.contrib.auth import authenticate
from environ import ImproperlyConfigured
from net.xeaf.rack.enums import AuthRejectReason
from net.xeaf.rack.managers import CryptoManager
from net.xeaf.rack.models import AccountModel
from net.xeaf.rack.models import SessionModel
from net.xeaf.rack.utils.exceptions import UnauthorizedException
from .account_manager import AccountManager
from ..utils.settings import Config
class SessionManager[T:SessionModel]:
"""
Реализует методы работы с Сессиями
"""
DEFAULT_SESSION_TTL = 3600
""" Время жизни сессии по умолчанию """
__model_class: Type[T] | None = None
""" Хранилище для класса сессии """
@classmethod
def authenticate(cls, username: str, password: str, device_id: str | None = None) -> T:
"""
Авторизует новую сессию
:param username: Имя пользователя
:param password: Пароль
:param device_id: Идентификатор устройства
:return: Объект данных сессии
"""
# Отсекаем ошибки активности и регистрации
reason = AccountManager.check_username_auth(username)
if reason is not None:
cls._raise_auth_error(reason)
# noinspection PyTypeChecker
account: AccountModel | None = authenticate(username=username, password=password)
# Обрабатываем ошибки проверки пароля
if account is None:
cls._raise_auth_error(AuthRejectReason.INVALID_USERNAME_OR_PASSWORD)
# Создаем и возвращаем объект сессии
AccountManager.restore_account(account)
session = cls.open_session(account, account, device_id)
return session
@classmethod
def find_by_token(cls, token: str) -> T | None:
"""
Возвращает объект данных сессии по токену
:param token: Токен
:return: Объект данных сессии или None, если не найдена или просрочена
"""
model = cls._get_session_model_class()
session = model.objects.filter(token__exact=token).first()
if session is not None and not session.is_expired():
return session
return None
@classmethod
def open_session(cls, account: AccountModel, actual_account: AccountModel, device_id: str | None = None) -> T:
"""
Открывает сессию для аккаунта
:param account: Модель данных Аккаунта пользователя
:param actual_account: Актуальный Аккаунт пользователя
:param device_id: Идентификатор устройства
:return: Информация о сессии
"""
session = cls._create_session_model(account, actual_account)
session.device_id = device_id
cls.renew_session(session)
return session
@classmethod
def close_session(cls, session: T) -> None:
"""
Закрывает сессию пользователя
:param session: Модель данных сессии
"""
session.expire(save=True)
@classmethod
def renew_session(cls, session: T) -> None:
"""
Обновляет время существование сессии
:param session: Модель данных сессии
"""
ttl = Config.get_int("SESSION_TTL", cls.DEFAULT_SESSION_TTL)
session.renew(ttl, save=True)
@classmethod
def _create_session_model(cls, account: AccountModel, actual_account: AccountModel) -> T:
"""
Создает новую модель Сессии
:param account: Аккаунт пользователя
:param actual_account: Актуальный Аккаунт пользователя
"""
model = cls._get_session_model_class()
return model.objects.create(
token=CryptoManager.gen_token(),
account=account,
actual_account=actual_account,
)
@classmethod
def _get_session_model_class(cls) -> Type[T]:
"""
Возвращает класс модели данных сессии
:return: Класс модели данных сессии
"""
if cls.__model_class is None:
model_path = Config.get_str("AUTH_SESSION_MODEL", "net_xeaf_rack.SessionModel")
app_label, model_name = model_path.split(".")
try:
model = apps.get_model(app_label, model_name)
except LookupError:
model = None
# Нет класса
if not model:
raise ImproperlyConfigured("Correct AUTH_SESSION_MODEL must be set in settings.py")
# Некорректный класс
if not issubclass(model, SessionModel):
raise ImproperlyConfigured(f"AUTH_SESSION_MODEL must be a subclass of {SessionModel.__name__}")
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

@@ -0,0 +1,14 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет классов функционала среднего звена
"""
from .custom_exception_handler import custom_exception_handler
from .custom_logging_middleware import CustomLoggingMiddleware
from .expiring_token_authentication import ExpiringTokenAuthentication

View File

@@ -0,0 +1,136 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание функции custom_exception_handler
"""
from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.exceptions import NotAuthenticated
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from net.xeaf.rack.core import CoreException
from net.xeaf.rack.models.responses import ErrorResponse
from net.xeaf.rack.utils.exceptions import ForbiddenException
from net.xeaf.rack.utils.exceptions import NotFoundException
from net.xeaf.rack.utils.exceptions import UnauthorizedException
def custom_exception_handler(exc: CoreException | Exception, context: dict) -> Response:
"""
Универсальный обработчик исключений
:param exc: Исключение
:param context: Контекст
:return: Ответ
"""
# Получаем информацию о запросе
request = context.get("request")
assert isinstance(request, Request)
# Это наше исключение
if isinstance(exc, CoreException):
return _make_error_response(
status_code=exc.status_code,
detail=exc.detail,
meta=exc.meta,
pure_status=exc.is_pure_status(),
exc_info=exc)
# Проверка на стандартное исключение валидации
if isinstance(exc, ValidationError):
return _process_validation_exception(exc)
# Проверка на стандартное исключение авторизации
if isinstance(exc, NotAuthenticated):
return _make_error_response(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=UnauthorizedException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=False,
exc_info=exc)
# Проверка на стандартное исключение прав доступа
if isinstance(exc, PermissionDenied):
return _make_error_response(
status_code=status.HTTP_403_FORBIDDEN,
detail=ForbiddenException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=False,
exc_info=exc)
# Проверка на стандартное исключение отсутствия реализации
if isinstance(exc, MethodNotAllowed):
return _make_error_response(
status_code=status.HTTP_404_NOT_FOUND,
detail=NotFoundException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=True,
exc_info=exc)
# Если ничего другое не прошло
return _make_error_response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
meta=None,
pure_status=True,
exc_info=exc)
def _process_validation_exception(exc: ValidationError) -> ErrorResponse:
"""
Обрабатывает частный случай стандартного исключения валидации
:param exc: Исключение
:return: Ответ
"""
# Обработка списка
if isinstance(exc.detail, list):
if len(exc.detail) == 1:
detail = str(exc.detail[0])
else:
detail = exc.detail
# Обработка словаря
elif isinstance(exc.detail, dict):
detail = {}
for field, error_info in exc.detail.items():
if isinstance(error_info, list):
error = error_info[0]
detail[field] = str(error)
# Обработка остальных типов
else:
detail = str(exc.detail)
return _make_error_response(status_code=status.HTTP_400_BAD_REQUEST, detail=detail, meta=None, pure_status=False, exc_info=exc)
def _make_error_response(status_code: int, detail: list | dict | str | bool | None, meta: dict | None, pure_status: bool,
exc_info: Exception) -> ErrorResponse:
"""
Создает ответ с ошибкой
:param status_code: Код статуса
:param detail: Детали ошибки
:param meta: Дополнительная информация
:param pure_status: Признак отправки чистого кода состояния
:param exc_info: Исключение
:return: Ответ
"""
result = ErrorResponse(status_code=status_code, detail=detail, meta=meta, pure_status=pure_status)
result._raised_exception = exc_info
return result

View File

@@ -0,0 +1,217 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CustomLoggingMiddleware
"""
import json
import logging
import traceback
from logging import Logger
from typing import Any
from django.conf import settings
from django.http import HttpRequest
from django.http import HttpResponse
from rest_framework import status
class CustomLoggingMiddleware:
"""
Класс для логирования запросов и ответов DRF
"""
DEFAULT_SENSITIVE_KEYS = {'password', 'old_password', 'new_password', 'token', 'access_token'}
""" Словарь ключей, которые нужно замаскировать в логах """
MAX_LOG_LENGTH = 2048
""" Максимальная длина объекта данных лога """
sensitive_keys: set = {}
""" Актуальный словарь ключей, которые нужно замаскировать в логах """
logger: Logger
""" Объект доступа к логгеру """
def __init__(self, get_response):
"""
Инициализация
"""
self.get_response = get_response
self.logger = logging.getLogger(settings.LOGGER_NAME)
self.sensitive_keys = set(self.DEFAULT_SENSITIVE_KEYS)
if settings.LOGGER_SENSITIVE_KEYS:
self.sensitive_keys.update(settings.LOGGER_SENSITIVE_KEYS)
def __call__(self, request: HttpRequest):
"""
Собираем данные запроса и отправляем в лог
"""
# Обрабатываем данные запроса
path = request.path
method = request.method
query_params = self._process_query_params(request)
# Передаем запрос дальше по конвейеру Django / DRF
response = self.get_response(request)
# Обрабатываем данные запроса, ответа и перехваченного исключения
body = self._process_body(request)
exc = getattr(response, "_raised_exception", None)
status_code = response.status_code
request_id = request.META.get('HTTP_X_REQUEST_ID', '')
response_content = self._process_response(response)
# Обрабатываем данные пользователя
user_id = ""
if hasattr(request, 'user'):
if request.user.is_authenticated:
user_id = str(request.user.id)
# Формируем единое сообщение для лога
log_parts = [
f"{status_code} [{method}] {path}"
]
if user_id:
log_parts.append(f"User ID: {user_id}")
if request_id:
log_parts.append(f"Request ID: {request_id}")
if query_params:
log_parts.append(f"Query Params: {query_params}")
if body:
log_parts.append(f"Request Body: {body}")
if response_content:
log_parts.append(f"Response Content: {response_content}")
log_message = "\n".join(log_parts) + "\n"
# Создаем и записываем лог
self._write_log(log_message, status_code, exc)
return response
def _process_query_params(self, request: HttpRequest) -> dict[str, Any]:
"""
Обрабатывает параметры запроса и маскирует конфиденциальные данные
:param request: Объект запроса
:return: Обработанные параметры запроса
"""
raw_query_params = dict(request.GET.lists())
return self._mask_sensitive_data(raw_query_params)
def _process_body(self, request: HttpRequest) -> str:
"""
Обрабатывает тело запроса и маскирует конфиденциальные данные
:param request: Объект запроса
:return: Обработанное тело запроса
"""
if request.body:
return self._process_data(request.body)
return ""
def _process_response(self, response: HttpResponse) -> str:
"""
Обрабатываем данные ответа
:param response: HttpResponse
:return: Строковое представление данных ответа
"""
if hasattr(response, 'content'):
return self._process_data(response.content)
return ""
def _process_data(self, data: Any) -> str:
"""
Обрабатывает данные запроса или ответа и возвращает их в виде строки
:param data: Данные запроса или ответа
:return: Обработанные данные в виде строки
"""
data_len = len(data)
if data_len > self.MAX_LOG_LENGTH:
return f"<Data too large to log (len={data_len})>"
try:
raw_response = data.decode('utf-8')
try:
data_json = json.loads(raw_response)
masked_response = self._mask_sensitive_data(data_json)
result = json.dumps(masked_response, ensure_ascii=False, indent=4)
except json.JSONDecodeError:
result = raw_response
except UnicodeDecodeError:
result = f"<Binary/Streaming Data (len={data_len})>"
return result
def _write_log(self, log_message: str, status_code: int, exc: Exception | None):
"""
Записывает лог с учетом исключения
:param log_message: Сообщение лога
:param status_code: Код статуса ответа
:param exc: Исключение
"""
if exc or status_code >= status.HTTP_400_BAD_REQUEST:
if exc:
tb_string = ''.join(traceback.format_exception(
type(exc), # тип исключения
exc, # сам объект исключения
exc.__traceback__ # объект traceback
))
log_message += f"\n{tb_string}\n"
if status_code == status.HTTP_400_BAD_REQUEST:
self.logger.debug(log_message)
elif status_code == status.HTTP_401_UNAUTHORIZED:
self.logger.warning(log_message)
elif status_code == status.HTTP_403_FORBIDDEN:
self.logger.warning(log_message)
elif status_code == status.HTTP_404_NOT_FOUND:
self.logger.error(log_message)
else:
self.logger.critical(log_message)
else:
self.logger.debug(log_message)
def _mask_sensitive_data(self, data: Any) -> Any:
"""
Рекурсивно маскирует конфиденциальные данные в словарях и списках
:param data: Данные для маскировки
:return: Маскированные данные
"""
if isinstance(data, dict):
return {
key: '***' if key.lower() in self.sensitive_keys else self._mask_sensitive_data(value)
for key, value in data.items()
}
elif isinstance(data, list):
return [self._mask_sensitive_data(item) for item in data]
return data

View File

@@ -0,0 +1,75 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ExpiringTokenAuthentication
"""
from typing import Any
from rest_framework.authentication import TokenAuthentication
from rest_framework.request import Request
from net.xeaf.rack.managers import AccountManager
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, SessionModel | 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, SessionModel | 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 self._check_account(account):
return None, None
SessionManager.renew_session(session)
return session.account, session
@classmethod
def _check_account(cls, account: AccountModel) -> bool:
"""
Проверка учетной записи
:param account: Учетная запись
"""
# Можно прошедшим проверку и не удаленным
reject = AccountManager.check_account_auth(account)
result = reject is None and not account.is_deleted()
return result

View File

@@ -0,0 +1,84 @@
# Generated by Django 6.0.7 on 2026-07-14 09:26
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=dict, 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(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')),
('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'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0.7 on 2026-07-20 07:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('net_xeaf_rack', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='sessionmodel',
name='account',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='rack.models.account'),
),
]

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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=dict,
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")

View File

@@ -0,0 +1,67 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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.models.mixins import CreatedAtMixin
class JournalModel(CoreModel, CreatedAtMixin):
"""
Модель журнала работы пользователей
"""
account = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=False,
blank=False,
related_name="fk_journal_model_account",
verbose_name=_("rack.models.account"),
)
""" Аккаунт пользователя """
actual_account = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=False,
blank=False,
related_name="fk_journal_model_actual_account",
verbose_name=_("rack.models.session_model.actual_account"),
)
""" Актуальный аккаунт пользователя """
action = models.CharField(
max_length=36,
null=False,
blank=False,
verbose_name=_("rack.models.journal_model.action"),
)
""" Действие """
class Meta:
"""
Параметры конфигурации класса
"""
# Индексы для производительности
indexes = [
models.Index(fields=["account"]),
models.Index(fields=["actual_account"]),
]
# Данные таблицы
db_table = "rack_journal"
verbose_name = _("rack.models.journal")
verbose_name_plural = _("rack.models.journal_plural")

View File

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

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,66 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса DeletedAtMixin
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from net.xeaf.rack.utils import DateUtils
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 = DateUtils.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,34 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса DescriptionMixin
"""
from django.db import models
from django.utils.translation import gettext_lazy as _
class DescriptionMixin(models.Model):
"""
Добавляет к модели свойство Описание
"""
description = models.TextField(
max_length=2048,
editable=True,
blank=True,
null=True,
verbose_name=_("rack.mixin.description"),
)
""" Описание """
class Meta:
"""
Конфигурация класс
"""
abstract = True

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса UpdatedAtMixin
"""
from datetime import datetime
from django.db import models
from django.utils.translation import gettext_lazy as _
from net.xeaf.rack.utils import DateUtils
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 = DateUtils.now()
if save:
self.save()
class Meta:
"""
Конфигурация класса
"""
abstract = True

View File

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

View File

@@ -0,0 +1,16 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет описания классов моделей ответов по протоколу HTTP
"""
from .bool_response import BoolResponse
from .data_response import DataResponse
from .empty_response import EmptyResponse
from .error_response import ErrorResponse
from .exception_response import ExceptionResponse
from .list_response import ListResponse

View File

@@ -0,0 +1,33 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса BoolResponse
"""
from rest_framework import status
from net.xeaf.rack.core import CoreResponse
class BoolResponse(CoreResponse):
"""
Ответ с логическим значением
"""
def __init__(self, value: bool, meta: dict | None = None):
"""
Инициализация
:param value: Логическое значение
:param meta: Дополнительная информация
"""
super(BoolResponse, self).__init__(
internal_status=status.HTTP_200_OK,
external_status=status.HTTP_200_OK,
data=value,
meta=meta)

View File

@@ -0,0 +1,33 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса DataResponse
"""
from rest_framework import status
from net.xeaf.rack.core import CoreResponse
class DataResponse(CoreResponse):
"""
Ответ с одним объектом данных
"""
def __init__(self, data: dict, meta: dict | None = None):
"""
Инициализация
:param data: Объект данных
:param meta: Дополнительная информация
"""
super(DataResponse, self).__init__(
internal_status=status.HTTP_200_OK,
external_status=status.HTTP_200_OK,
data=data,
meta=meta)

View File

@@ -0,0 +1,30 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса EmptyResponse
"""
from rest_framework import status
from net.xeaf.rack.core import CoreResponse
class EmptyResponse(CoreResponse):
"""
Пустой положительный ответ
"""
def __init__(self):
"""
Инициализация
"""
super(EmptyResponse, self).__init__(
internal_status=status.HTTP_200_OK,
external_status=status.HTTP_200_OK,
data=None,
meta=None)

View File

@@ -0,0 +1,51 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ErrorResponse
"""
from rest_framework import status
from net.xeaf.rack.core import CoreResponse
class ErrorResponse(CoreResponse):
"""
Ответ с информацией об ошибке
"""
def __init__(self, status_code: int, detail: list | dict | str | bool | None, meta: dict | None = None, pure_status: bool = False):
"""
Инициализация
:param status_code: Код состояния
:param detail: Информация об ошибке
:param meta: Дополнительные данные
:param pure_status: Возвращать настоящий код состояния
"""
super(ErrorResponse, self).__init__(
internal_status=status_code,
external_status=self._calc_external_status(status_code, pure_status),
data=detail,
meta=meta)
@classmethod
def _calc_external_status(cls, status_code: int, pure_status: bool) -> int:
"""
Вычисляет значение внешнего возвращаемого статуса
:param status_code: Код состояния
:param pure_status: Возвращать настоящий код состояния
:return: Внешний код состояния
"""
if status_code < status.HTTP_500_INTERNAL_SERVER_ERROR and not pure_status:
return status.HTTP_200_OK
return status_code

View File

@@ -0,0 +1,40 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ExceptionResponse
"""
from rest_framework import status
from net.xeaf.rack.core import CoreException
from net.xeaf.rack.core import CoreResponse
class ExceptionResponse(CoreResponse):
"""
Ответ с информацией об исключении
"""
def __init__(self, exc: CoreException, meta: dict | None = None):
"""
Инициализация
:param exc: Исключение
:param meta: Дополнительная информация
"""
# Проверяем требование передать реальный статус
if exc.is_pure_status():
external_status = exc.status_code
else:
external_status = status.HTTP_200_OK
super(ExceptionResponse, self).__init__(
internal_status=exc.status_code,
external_status=external_status,
data=exc.detail,
meta=meta)

View File

@@ -0,0 +1,33 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ListResponse
"""
from rest_framework import status
from net.xeaf.rack.core import CoreResponse
class ListResponse(CoreResponse):
"""
Ответ со списком объектов данных
"""
def __init__(self, data: list, meta: dict | None = None):
"""
Инициализация
:param data: Список объектов данных
:param meta: Дополнительная информация
"""
super(ListResponse, self).__init__(
internal_status=status.HTTP_200_OK,
external_status=status.HTTP_200_OK,
data=data,
meta=meta)

View File

@@ -0,0 +1,95 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: 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.models.mixins import CreatedAtMixin
from net.xeaf.rack.models.mixins import ExpiresAtMixin
class SessionModel(CoreModel, CreatedAtMixin, ExpiresAtMixin):
"""
Модель сессии пользователя
"""
MAX_TOKEN_LENGTH = 255
""" Максимальная длина токена сессии """
token = models.CharField(
max_length=MAX_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.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")

View File

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

View File

@@ -0,0 +1,92 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса AccountTestData
"""
import uuid
from net.xeaf.rack.models import AccountModel
from net.xeaf.rack.utils import DateUtils
class AccountTestData:
"""
Инициализирует модели данных тестовых Аккаунтов
"""
IVANOV_ID = uuid.UUID("bd428358-6944-4657-8044-a3dc1c4dff8a")
""" Идентификатор пользователя Иван Иванов """
IVANOV_USERNAME = 'i.ivanov@xeaf.net'
""" Имя пользователя Иван Иванов """
PETROV_ID = uuid.UUID("9c5010cc-f3be-42ae-bc05-d142d68a610a")
""" Идентификатор пользователя Петр Петров """
PETROV_USERNAME = 'p.petrov@xeaf.net'
""" Имя пользователя Петр Петров """
DEFAULT_PASSWORD = "manager123!"
""" Пароль по умолчанию для тестов """
@classmethod
def init_accounts(cls):
"""
Инициализирует Аккаунты пользователя
"""
cls.init_ivanov_account()
cls.init_petrov_account()
@classmethod
def init_ivanov_account(cls):
"""
Инициализирует Аккаунт пользователя Иван Иванов
"""
account = AccountModel()
account.id = cls.IVANOV_ID
account.username = "i.ivanov@xeaf.net"
account.nick_name = "Иван"
account.full_name = "Иван И. Иванов"
account.email = "i.ivanov@xeaf.net"
account.set_password(cls.DEFAULT_PASSWORD)
account.joined_at = DateUtils.now()
account.save()
@classmethod
def init_petrov_account(cls):
"""
Инициализирует Аккаунт пользователя Петр Петров
"""
account = AccountModel()
account.id = cls.PETROV_ID
account.username = "p.petrov@xeaf.net"
account.nick_name = "Петр"
account.full_name = "Петр П. Петров"
account.email = "p.petrov@xeaf.net"
account.set_password(cls.DEFAULT_PASSWORD)
account.joined_at = DateUtils.now()
account.save()
@classmethod
def get_ivanov_account(cls):
"""
Возвращает аккаунт пользователя Иван Иванов
"""
return AccountModel.objects.get(id=cls.IVANOV_ID)
@classmethod
def get_petrov_account(cls):
"""
Возвращает аккаунт пользователя Петр Петров
"""
return AccountModel.objects.get(id=cls.PETROV_ID)

View File

@@ -0,0 +1,82 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса SessionTestData
"""
from net.xeaf.rack.models import SessionModel
from .account_test_data import AccountTestData
class SessionTestData:
"""
Инициализирует модели данных тестовых Сессий пользователей
"""
IVANOV_SESSION_TOKEN = "ced4217bbd12b10a59a3f9342164793819a29238"
""" Токен сессии пользователя Иван Иванов """
PETROV_SESSION_TOKEN = "1e20d0f236efdda2f31688b6c176bf817cb3357e"
""" Токен сессии пользователя Петр Петров """
TEST_SESSION_TTL = 3600
""" Время жизни тестовой сессии """
@classmethod
def init_sessions(cls):
"""
Инициализирует сессии для тестовых пользователей
"""
cls.init_ivanov_session()
cls.init_petrov_session()
@classmethod
def init_ivanov_session(cls):
"""
Создает Сессию для пользователя Иван Иванов
"""
session = SessionModel()
session.token = cls.IVANOV_SESSION_TOKEN
session.account_id = AccountTestData.IVANOV_ID
session.actual_account_id = AccountTestData.IVANOV_ID
session.renew(cls.TEST_SESSION_TTL)
session.save()
@classmethod
def init_petrov_session(cls):
"""
Создает Сессию для пользователя Петр Петров
"""
session = SessionModel()
session.token = cls.PETROV_SESSION_TOKEN
session.account_id = AccountTestData.PETROV_ID
session.actual_account_id = AccountTestData.PETROV_ID
session.renew(cls.TEST_SESSION_TTL)
session.save()
@classmethod
def get_ivanov_session(cls) -> SessionModel:
"""
Возвращает сессию пользователя Иван Иванов
:return: Сессия пользователя Иван Иванов
"""
return SessionModel.objects.get(token=cls.IVANOV_SESSION_TOKEN)
@classmethod
def get_petrov_session(cls) -> SessionModel:
"""
Возвращает сессию пользователя Петр Петров
:return: Сессия пользователя Петр Петров
"""
return SessionModel.objects.get(token=cls.PETROV_SESSION_TOKEN)

View File

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

View File

@@ -0,0 +1,26 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса VersionInfoResponse
"""
from rest_framework import serializers
class VersionInfoResponse(serializers.Serializer):
"""
Формирует объект Информации о версии приложения
"""
title = serializers.CharField()
""" Наименование """
version = serializers.CharField()
""" Номер версии API """
debug_mode = serializers.BooleanField()
""" Признак режима отладки """

View File

@@ -0,0 +1,19 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет описания классов тестов
"""
import sys
import warnings
if 'test' in sys.argv:
warnings.filterwarnings(
"ignore",
message="Model .* was already registered",
category=RuntimeWarning
)

View File

@@ -0,0 +1,23 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет описания тестов классов ядра
"""
from .core_command_clear_database_tests import CoreCommandClearDatabaseTests
from .core_command_is_debug_or_test_tests import CoreCommandIsDebugOrTestTests
from .core_command_report_int_value_tests import CoreCommandReportIntValueTests
from .core_command_report_separator_tests import CoreCommandReportSeparatorTests
from .core_enum_check_is_empty_tests import CoreEnumCheckIsEmptyTests
from .core_enum_choices_tests import CoreEnumChoicesTests
from .core_enum_from_string_tests import CoreEnumFromStringTests
from .core_enum_keys_tests import CoreEnumKeysTests
from .core_enum_values_tests import CoreEnumValuesTests
from .core_exception_is_pure_status_tests import CoreExceptionIsPureStatusTests
from .core_exception_process_meta_tests import CoreExceptionProcessMetaTests
from .core_exception_tests import CoreExceptionTests
from .core_response_tests import CoreResponseTests

View File

@@ -0,0 +1,25 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание mock-классов для тестов CoreCommand
"""
from net.xeaf.rack.core import CoreCommand
class TestCommand(CoreCommand):
"""
Тестовая реализация команды для проверки базовых методов вывода отчетов
"""
def handle(self, *args, **options):
"""
Обязательный метод для Django BaseCommand
"""
pass

View File

@@ -0,0 +1,66 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание mock-классов для тестов CoreEnum
"""
from net.xeaf.rack.core import CoreEnum
class TestEnum(CoreEnum):
"""
Тестовое перечисление для проверки методов choices, keys, values
"""
OPTION1 = "value1"
OPTION2 = "value2"
OPTION3 = "value3"
class TestOrderedEnum(CoreEnum):
"""
Тестовое перечисление с упорядоченными элементами
"""
FIRST = "first"
SECOND = "second"
THIRD = "third"
class TestDuplicateEnum(CoreEnum):
"""
Тестовое перечисление с дублирующимися значениями
"""
ONE = "same"
TWO = "same"
THREE = "different"
class TestCaseEnum(CoreEnum):
"""
Тестовое перечисление с элементами разных регистров
"""
LOW = "lower"
UPPER = "UPPER"
class TestSingleEnum(CoreEnum):
"""
Тестовое перечисление с одним элементом
"""
ONLY = "only_value"
class TestEmptyEnum(CoreEnum):
"""
Пустое тестовое перечисление
"""
pass

View File

@@ -0,0 +1,40 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreCommandClearDatabaseTests
"""
from net.xeaf.rack.core.testing import CoreUserStoryTestCase
from net.xeaf.rack.models.user_story import AccountTestData
from ._core_command_mocks import TestCommand
class CoreCommandClearDatabaseTests(CoreUserStoryTestCase):
"""
Тестовый класс для CoreCommand._clear_database()
"""
def test_clear_database_removes_all_user_stories_from_db(self):
"""
Тест проверяет, что после вызова _clear_database известные объекты User Story удаляются из БД
"""
# Arrange
command_instance = TestCommand()
# Проверяем, что перед тестом база наполнена (Иванов существует в БД)
ivanov_id = AccountTestData.IVANOV_ID
account_model = AccountTestData.get_ivanov_account().__class__
self.assertTrue(account_model.objects.filter(id=ivanov_id).exists())
# Act
command_instance._clear_database()
# Assert
# Проверяем реальное состояние БД: объектов больше быть не должно
self.assertFalse(account_model.objects.filter(id=ivanov_id).exists())
self.assertEqual(account_model.objects.count(), 0)

View File

@@ -0,0 +1,177 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreCommandIsDebugOrTestTests
"""
import sys
from django.conf import settings
from net.xeaf.rack.core import CoreCommand
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreCommandIsDebugOrTestTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreCommand._is_debug_or_test()
"""
def test_is_debug_or_test_returns_true_when_debug_enabled(self):
"""
Тест проверяет, что метод возвращает True при включенном DEBUG
"""
# Arrange
original_debug = settings.DEBUG
settings.DEBUG = True
original_argv = sys.argv.copy()
try:
# Act
result = CoreCommand._is_debug_or_test()
# Assert
self.assertTrue(result)
finally:
# Cleanup
settings.DEBUG = original_debug
sys.argv = original_argv
def test_is_debug_or_test_returns_true_when_test_in_argv(self):
"""
Тест проверяет, что метод возвращает True, когда в аргументах командной строки присутствует 'test'
"""
# Arrange
original_debug = settings.DEBUG
settings.DEBUG = False
original_argv = sys.argv.copy()
sys.argv = ['manage.py', 'test']
try:
# Act
result = CoreCommand._is_debug_or_test()
# Assert
self.assertTrue(result)
finally:
# Cleanup
settings.DEBUG = original_debug
sys.argv = original_argv
def test_is_debug_or_test_returns_false_when_debug_disabled_and_test_not_in_argv(self):
"""
Тест проверяет, что метод возвращает False, когда DEBUG выключен и 'test' отсутствует в аргументах
"""
# Arrange
original_debug = settings.DEBUG
settings.DEBUG = False
original_argv = sys.argv.copy()
sys.argv = ['manage.py', 'runserver']
try:
# Act
result = CoreCommand._is_debug_or_test()
# Assert
self.assertFalse(result)
finally:
# Cleanup
settings.DEBUG = original_debug
sys.argv = original_argv
def test_is_debug_or_test_returns_true_when_debug_enabled_and_test_in_argv(self):
"""
Тест проверяет, что метод возвращает True, когда и DEBUG включен, и 'test' присутствует в аргументах
"""
# Arrange
original_debug = settings.DEBUG
settings.DEBUG = True
original_argv = sys.argv.copy()
sys.argv = ['manage.py', 'test', '--verbosity=2']
try:
# Act
result = CoreCommand._is_debug_or_test()
# Assert
self.assertTrue(result)
finally:
# Cleanup
settings.DEBUG = original_debug
sys.argv = original_argv
def test_is_debug_or_test_handles_test_substring_correctly(self):
"""
Тест проверяет, что метод правильно обрабатывает наличие подстроки 'test' в аргументах
"""
# Arrange
original_debug = settings.DEBUG
settings.DEBUG = False
original_argv = sys.argv.copy()
sys.argv = ['manage.py', 'testing_tool']
try:
# Act
result = CoreCommand._is_debug_or_test()
# Assert
# Метод должен проверять точное вхождение 'test', а не подстроку
self.assertFalse(result)
finally:
# Cleanup
settings.DEBUG = original_debug
sys.argv = original_argv
def test_is_debug_or_test_returns_false_with_empty_argv(self):
"""
Тест проверяет поведение метода при пустом списке аргументов
"""
# Arrange
original_debug = settings.DEBUG
settings.DEBUG = False
original_argv = sys.argv.copy()
sys.argv = []
try:
# Act
result = CoreCommand._is_debug_or_test()
# Assert
self.assertFalse(result)
finally:
# Cleanup
settings.DEBUG = original_debug
sys.argv = original_argv
def test_is_debug_or_test_handles_nonexistent_settings_attribute(self):
"""
Тест проверяет, что метод корректно обрабатывает отсутствие атрибута DEBUG в настройках
"""
# Arrange
original_debug = getattr(settings, 'DEBUG', None)
if hasattr(settings, 'DEBUG'):
delattr(settings, 'DEBUG')
original_argv = sys.argv.copy()
sys.argv = ['manage.py', 'runserver']
try:
# Act & Assert
# В этом случае должно возникнуть исключение, так как settings.DEBUG отсутствует
with self.assertRaises(AttributeError):
CoreCommand._is_debug_or_test()
finally:
# Cleanup
if original_debug is not None:
settings.DEBUG = original_debug
sys.argv = original_argv

View File

@@ -0,0 +1,317 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreCommandReportIntValueTests
"""
import sys
from io import StringIO
from net.xeaf.rack.core import CoreCommand
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreCommandReportIntValueTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreCommand._report_int_value()
"""
def setUp(self):
"""
Подготовка тестовой среды: перехват stdout для проверки вывода
"""
super().setUp()
self.stdout_capture = StringIO()
self.original_stdout = sys.stdout
sys.stdout = self.stdout_capture
def tearDown(self):
"""
Восстановление stdout после каждого теста
"""
sys.stdout = self.original_stdout
super().tearDown()
def test_report_int_value_with_default_length(self):
"""
Тест проверяет вывод целочисленного значения с длиной по умолчанию
"""
# Arrange
name = "Total records"
value = 42
expected_length = 70
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn(str(value), output)
self.assertTrue(output.endswith(str(value)))
# Проверяем, что общая длина строки соответствует ожидаемой
# (без учета возможных пробелов в конце)
self.assertEqual(len(output), expected_length)
def test_report_int_value_with_custom_length(self):
"""
Тест проверяет вывод целочисленного значения с пользовательской длиной
"""
# Arrange
name = "Count"
value = 100
custom_length = 50
# Act
CoreCommand._report_int_value(name, value, custom_length)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn(str(value), output)
self.assertEqual(len(output), custom_length)
def test_report_int_value_with_small_value(self):
"""
Тест проверяет вывод маленького целочисленного значения
"""
# Arrange
name = "Errors"
value = 0
expected_length = 70
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn("0", output)
self.assertEqual(len(output), expected_length)
def test_report_int_value_with_large_value(self):
"""
Тест проверяет вывод большого целочисленного значения
"""
# Arrange
name = "Processed items"
value = 1234567890
expected_length = 70
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn(str(value), output)
self.assertEqual(len(output), expected_length)
def test_report_int_value_with_negative_value(self):
"""
Тест проверяет вывод отрицательного целочисленного значения
"""
# Arrange
name = "Balance"
value = -50
expected_length = 70
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn("-50", output)
self.assertEqual(len(output), expected_length)
def test_report_int_value_with_empty_name(self):
"""
Тест проверяет вывод значения с пустым названием
"""
# Arrange
name = ""
value = 42
expected_length = 70
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(str(value), output)
# При пустом имени будут только точки и значение
self.assertTrue(output.startswith("."))
self.assertEqual(len(output), expected_length)
def test_report_int_value_with_long_name(self):
"""
Тест проверяет вывод значения с длинным названием
"""
# Arrange
name = "This is a very long name that should be truncated or handled"
value = 42
expected_length = 70
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn(str(value), output)
self.assertEqual(len(output), expected_length)
def test_report_int_value_with_minimal_length(self):
"""
Тест проверяет вывод значения с минимальной длиной
"""
# Arrange
name = "X"
value = 1
minimal_length = 5 # Должно быть достаточно для "X ... 1"
# Act
CoreCommand._report_int_value(name, value, minimal_length)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn(str(value), output)
self.assertEqual(len(output), minimal_length)
def test_report_int_value_with_length_smaller_than_needed(self):
"""
Тест проверяет поведение при длине меньше необходимой для отображения
"""
# Arrange
name = "Test"
value = 12345
too_small_length = 10
# Act
CoreCommand._report_int_value(name, value, too_small_length)
# Assert
output = self.stdout_capture.getvalue().strip()
# Метод должен обработать ситуацию без ошибок
# Возможно, обрежет название или значение
self.assertIsInstance(output, str)
self.assertLessEqual(len(output), too_small_length)
def test_report_int_value_with_zero_length(self):
"""
Тест проверяет поведение при нулевой длине
"""
# Arrange
name = "Test"
value = 42
zero_length = 0
# Act
CoreCommand._report_int_value(name, value, zero_length)
# Assert
output = self.stdout_capture.getvalue().strip()
# При нулевой длине вывод должен быть пустым или минимальным
self.assertIsInstance(output, str)
def test_report_int_value_format_with_dots(self):
"""
Тест проверяет, что между названием и значением выводятся точки
"""
# Arrange
name = "Records"
value = 100
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
# Проверяем, что между названием и значением есть точки
name_end_index = output.find(name) + len(name)
value_start_index = output.rfind(str(value))
middle_part = output[name_end_index:value_start_index]
self.assertTrue(all(c == '.' for c in middle_part.replace(' ', '')))
def test_report_int_value_with_multiple_calls(self):
"""
Тест проверяет последовательный вывод нескольких значений
"""
# Arrange
test_data = [
("First", 100),
("Second", 200),
("Third", 300)
]
# Act
for name, value in test_data:
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue()
lines = output.strip().split('\n')
self.assertEqual(len(lines), len(test_data))
for i, (name, value) in enumerate(test_data):
self.assertIn(name, lines[i])
self.assertIn(str(value), lines[i])
self.assertEqual(len(lines[i]), 70)
def test_report_int_value_with_unicode_characters(self):
"""
Тест проверяет вывод значения с названием, содержащим Unicode-символы
"""
# Arrange
name = "Количество записей"
value = 42
expected_length = 70
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn(str(value), output)
self.assertEqual(len(output), expected_length)
def test_report_int_value_with_maximum_integer(self):
"""
Тест проверяет вывод максимального целочисленного значения
"""
# Arrange
name = "Max int"
value = sys.maxsize
expected_length = 70
# Act
CoreCommand._report_int_value(name, value)
# Assert
output = self.stdout_capture.getvalue().strip()
self.assertIn(name, output)
self.assertIn(str(value), output)
self.assertEqual(len(output), expected_length)

View File

@@ -0,0 +1,229 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreCommandReportSeparatorTests
"""
import sys
from io import StringIO
from net.xeaf.rack.core import CoreCommand
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreCommandReportSeparatorTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreCommand._report_separator()
"""
def setUp(self):
"""
Подготовка тестовой среды: перехват stdout для проверки вывода
"""
super().setUp()
self.stdout_capture = StringIO()
self.original_stdout = sys.stdout
sys.stdout = self.stdout_capture
def tearDown(self):
"""
Восстановление stdout после каждого теста
"""
sys.stdout = self.original_stdout
super().tearDown()
def test_report_separator_with_default_parameters(self):
"""
Тест проверяет вывод разделителя с параметрами по умолчанию
"""
# Arrange
expected_length = 70
expected_separator = '=' * expected_length
# Act
CoreCommand._report_separator()
# Assert
output = self.stdout_capture.getvalue()
self.assertIn(expected_separator, output)
self.assertEqual(output.count('\n'), 1) # Только одна строка с разделителем
def test_report_separator_with_custom_length(self):
"""
Тест проверяет вывод разделителя с пользовательской длиной
"""
# Arrange
custom_length = 50
expected_separator = '=' * custom_length
# Act
CoreCommand._report_separator(length=custom_length)
# Assert
output = self.stdout_capture.getvalue()
self.assertIn(expected_separator, output)
self.assertEqual(len(expected_separator), custom_length)
def test_report_separator_with_single_line(self):
"""
Тест проверяет вывод одинарного разделителя
"""
# Arrange
expected_separator = '-' * 70
# Act
CoreCommand._report_separator(double=False)
# Assert
output = self.stdout_capture.getvalue()
self.assertIn(expected_separator, output)
self.assertNotIn('=' * 70, output)
def test_report_separator_with_double_line(self):
"""
Тест проверяет вывод двойного разделителя (поведение по умолчанию)
"""
# Arrange
expected_separator = '=' * 70
# Act
CoreCommand._report_separator(double=True)
# Assert
output = self.stdout_capture.getvalue()
self.assertIn(expected_separator, output)
def test_report_separator_with_first_new_line(self):
"""
Тест проверяет добавление пустой строки перед разделителем
"""
# Arrange
expected_separator = '=' * 70
# Act
CoreCommand._report_separator(first_new_line=True)
# Assert
output = self.stdout_capture.getvalue()
self.assertTrue(output.startswith('\n'))
self.assertIn(expected_separator, output)
def test_report_separator_with_last_new_line(self):
"""
Тест проверяет добавление пустой строки после разделителя
"""
# Arrange
expected_separator = '=' * 70
# Act
CoreCommand._report_separator(last_new_line=True)
# Assert
output = self.stdout_capture.getvalue()
self.assertIn(expected_separator, output)
self.assertTrue(output.endswith('\n'))
def test_report_separator_with_both_new_lines(self):
"""
Тест проверяет добавление пустых строк до и после разделителя
"""
# Arrange
expected_separator = '=' * 70
# Act
CoreCommand._report_separator(first_new_line=True, last_new_line=True)
# Assert
output = self.stdout_capture.getvalue()
self.assertTrue(output.startswith('\n'))
self.assertTrue(output.endswith('\n'))
self.assertIn(expected_separator, output)
def test_report_separator_with_minimal_length(self):
"""
Тест проверяет вывод разделителя минимальной длины
"""
# Arrange
minimal_length = 1
expected_separator = '=' * minimal_length
# Act
CoreCommand._report_separator(length=minimal_length)
# Assert
output = self.stdout_capture.getvalue()
self.assertIn(expected_separator, output)
self.assertEqual(len(expected_separator), minimal_length)
def test_report_separator_with_large_length(self):
"""
Тест проверяет вывод разделителя большой длины
"""
# Arrange
large_length = 200
expected_separator = '=' * large_length
# Act
CoreCommand._report_separator(length=large_length)
# Assert
output = self.stdout_capture.getvalue()
self.assertIn(expected_separator, output)
self.assertEqual(len(expected_separator), large_length)
def test_report_separator_combination_all_parameters(self):
"""
Тест проверяет комбинацию всех параметров
"""
# Arrange
custom_length = 60
expected_separator = '-' * custom_length
# Act
CoreCommand._report_separator(
length=custom_length,
double=False,
first_new_line=True,
last_new_line=True
)
# Assert
output = self.stdout_capture.getvalue()
self.assertTrue(output.startswith('\n'))
self.assertTrue(output.endswith('\n'))
self.assertIn(expected_separator, output)
self.assertNotIn('=' * custom_length, output)
def test_report_separator_no_output_with_zero_length(self):
"""
Тест проверяет поведение при нулевой длине разделителя
"""
# Act
CoreCommand._report_separator(length=0)
# Assert
output = self.stdout_capture.getvalue()
# При длине 0 разделитель не выводится, но пустые строки могут быть
self.assertNotIn('=' * 70, output)
self.assertNotIn('-' * 70, output)
def test_report_separator_handles_negative_length(self):
"""
Тест проверяет поведение при отрицательной длине разделителя
"""
# Arrange
negative_length = -10
# Act
CoreCommand._report_separator(length=negative_length)
# Assert
output = self.stdout_capture.getvalue()
# Отрицательная длина вызовет ошибку или приведет к пустой строке
# В зависимости от реализации, но метод должен отработать без ошибок
self.assertIsInstance(output, str)

View File

@@ -0,0 +1,52 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreEnumCheckIsEmptyTests
"""
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from ._core_enum_mocks import TestEmptyEnum
from ._core_enum_mocks import TestEnum
class CoreEnumCheckIsEmptyTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreEnum._check_is_empty()
"""
def test_check_is_empty_with_non_empty_enum_does_not_raise_error(self):
"""
Тест проверяет, что _check_is_empty не выбрасывает исключение для непустого перечисления
"""
# Act & Assert
try:
TestEnum._check_is_empty()
except ValueError:
self.fail("_check_is_empty raised ValueError unexpectedly for non-empty enum")
def test_check_is_empty_with_empty_enum_raises_value_error(self):
"""
Тест проверяет, что _check_is_empty выбрасывает ValueError для пустого перечисления"
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEmptyEnum._check_is_empty()
self.assertIn("enum must not be empty", str(context.exception))
def test_check_is_empty_uses_correct_class_name_in_error(self):
"""
Тест проверяет, что в сообщении об ошибке используется правильное имя класса
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEmptyEnum._check_is_empty()
self.assertNotIn("CoreEnum", str(context.exception))

View File

@@ -0,0 +1,77 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreEnumChoicesTests
"""
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from ._core_enum_mocks import TestEmptyEnum
from ._core_enum_mocks import TestEnum
from ._core_enum_mocks import TestOrderedEnum
from ._core_enum_mocks import TestSingleEnum
class CoreEnumChoicesTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreEnum.choices()
"""
def test_choices_returns_list_of_tuples_with_value_and_name(self):
"""
Тест проверяет, что метод choices возвращает список кортежей (значение, имя)
"""
# Arrange
expected = [("value1", "OPTION1"), ("value2", "OPTION2"), ("value3", "OPTION3")]
# Act
result = TestEnum.choices()
# Assert
self.assertEqual(result, expected)
self.assertIsInstance(result, list)
self.assertIsInstance(result[0], tuple)
self.assertEqual(len(result), 3)
def test_choices_returns_empty_list_for_empty_enum(self):
"""
Тест проверяет, что для пустого перечисления метод choices выбрасывает исключение
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEmptyEnum.choices()
self.assertIn("enum must not be empty", str(context.exception))
def test_choices_preserves_enum_member_order(self):
"""
Тест проверяет сохранение порядка элементов перечисления
"""
# Act
result = TestOrderedEnum.choices()
# Assert
self.assertEqual(result[0], ("first", "FIRST"))
self.assertEqual(result[1], ("second", "SECOND"))
self.assertEqual(result[2], ("third", "THIRD"))
def test_choices_handles_single_member_enum(self):
"""
Тест проверяет работу метода choices с перечислением из одного элемента
"""
# Arrange
expected = [("only_value", "ONLY")]
# Act
result = TestSingleEnum.choices()
# Assert
self.assertEqual(result, expected)
self.assertEqual(len(result), 1)

View File

@@ -0,0 +1,123 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreEnumFromStringTests
"""
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from ._core_enum_mocks import TestCaseEnum
from ._core_enum_mocks import TestDuplicateEnum
from ._core_enum_mocks import TestEmptyEnum
from ._core_enum_mocks import TestEnum
from ._core_enum_mocks import TestSingleEnum
class CoreEnumFromStringTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreEnum.from_string()
"""
def test_from_string_returns_correct_enum_member(self):
"""
Тест проверяет, что метод from_string возвращает корректный элемент перечисления
"""
# Act
result = TestEnum.from_string("value2")
# Assert
self.assertEqual(result, TestEnum.OPTION2)
self.assertEqual(result.value, "value2")
self.assertEqual(result.name, "OPTION2")
def test_from_string_returns_first_matching_member_for_duplicate_values(self):
"""
Тест проверяет, что при дублирующихся значениях возвращается первый найденный элемент
"""
# Act
result = TestDuplicateEnum.from_string("same")
# Assert
self.assertEqual(result, TestDuplicateEnum.ONE)
self.assertEqual(result.value, "same")
self.assertEqual(result.name, "ONE")
def test_from_string_raises_error_for_non_existent_value(self):
"""
Тест проверяет, что для несуществующего значения выбрасывается ValueError
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEnum.from_string("non_existent_value")
self.assertIn("enum does not contain the value 'non_existent_value'", str(context.exception))
def test_from_string_raises_error_for_empty_string(self):
"""
Тест проверяет, что для пустой строки выбрасывается ValueError
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEnum.from_string("")
self.assertIn("enum does not contain the value ''", str(context.exception))
def test_from_string_raises_error_for_empty_enum(self):
"""
Тест проверяет, что для пустого перечисления выбрасывается ValueError
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEmptyEnum.from_string("any_value")
self.assertIn("enum must not be empty", str(context.exception))
def test_from_string_works_with_single_member_enum(self):
"""
Тест проверяет работу метода from_string с перечислением из одного элемента
"""
# Act
result = TestSingleEnum.from_string("only_value")
# Assert
self.assertEqual(result, TestSingleEnum.ONLY)
self.assertEqual(result.value, "only_value")
self.assertEqual(result.name, "ONLY")
def test_from_string_case_sensitive_matching(self):
"""
Тест проверяет, что поиск значения чувствителен к регистру
"""
# Act
result_lower = TestCaseEnum.from_string("lower")
result_upper = TestCaseEnum.from_string("UPPER")
# Assert
self.assertEqual(result_lower, TestCaseEnum.LOW)
self.assertEqual(result_upper, TestCaseEnum.UPPER)
# Проверяем, что поиск с другим регистром вызывает ошибку
with self.assertRaises(ValueError):
TestCaseEnum.from_string("Lower")
def test_from_string_uses_correct_class_name_in_error(self):
"""
Тест проверяет, что в сообщении об ошибке используется правильное имя класса
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEnum.from_string("wrong")
self.assertIn("TestEnum", str(context.exception))
self.assertNotIn("CoreEnum", str(context.exception))

View File

@@ -0,0 +1,69 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreEnumKeysTests
"""
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from ._core_enum_mocks import TestEmptyEnum
from ._core_enum_mocks import TestEnum
from ._core_enum_mocks import TestOrderedEnum
from ._core_enum_mocks import TestSingleEnum
class CoreEnumKeysTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreEnum.keys()
"""
def test_keys_returns_list_of_enum_member_names(self):
"""
Тест проверяет, что метод keys возвращает список имен элементов перечисления
"""
# Arrange
expected = ["OPTION1", "OPTION2", "OPTION3"]
# Act
result = TestEnum.keys()
# Assert
self.assertEqual(result, expected)
self.assertIsInstance(result, list)
self.assertEqual(len(result), 3)
def test_keys_returns_empty_list_for_empty_enum(self):
"""
Тест проверяет, что для пустого перечисления метод keys выбрасывает исключение
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEmptyEnum.keys()
self.assertIn("enum must not be empty", str(context.exception))
def test_keys_returns_names_in_definition_order(self):
"""
Тест проверяет, что метод keys возвращает имена в порядке объявления
"""
# Act
result = TestOrderedEnum.keys()
# Assert
self.assertEqual(result, ["FIRST", "SECOND", "THIRD"])
def test_keys_works_with_single_member_enum(self):
"""
Тест проверяет работу метода keys с перечислением из одного элемента
"""
# Act
result = TestSingleEnum.keys()
# Assert
self.assertEqual(result, ["ONLY"])

View File

@@ -0,0 +1,85 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreEnumValuesTests
"""
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from ._core_enum_mocks import TestDuplicateEnum
from ._core_enum_mocks import TestEmptyEnum
from ._core_enum_mocks import TestEnum
from ._core_enum_mocks import TestOrderedEnum
from ._core_enum_mocks import TestSingleEnum
class CoreEnumValuesTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreEnum.values()
"""
def test_values_returns_list_of_enum_member_values(self):
"""
Тест проверяет, что метод values возвращает список значений элементов перечисления
"""
# Arrange
expected = ["value1", "value2", "value3"]
# Act
result = TestEnum.values()
# Assert
self.assertEqual(result, expected)
self.assertIsInstance(result, list)
self.assertEqual(len(result), 3)
def test_values_returns_empty_list_for_empty_enum(self):
"""
Тест проверяет, что для пустого перечисления метод values выбрасывает исключение
"""
# Act & Assert
with self.assertRaises(ValueError) as context:
TestEmptyEnum.values()
self.assertIn("enum must not be empty", str(context.exception))
def test_values_returns_values_in_definition_order(self):
"""
Тест проверяет, что метод values возвращает значения в порядке объявления
"""
# Act
result = TestOrderedEnum.values()
# Assert
self.assertEqual(result, ["first", "second", "third"])
def test_values_handles_duplicate_values_in_enum(self):
"""
Тест проверяет, что метод values корректно обрабатывает дублирующиеся значения
"""
# Arrange
expected = ["same", "same", "different"]
# Act
result = TestDuplicateEnum.values()
# Assert
self.assertEqual(result, expected)
self.assertEqual(len(result), 3)
def test_values_works_with_single_member_enum(self):
"""
Тест проверяет работу метода values с перечислением из одного элемента
"""
# Act
result = TestSingleEnum.values()
# Assert
self.assertEqual(result, ["only_value"])

View File

@@ -0,0 +1,50 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreExceptionIsPureStatusTests
"""
from rest_framework import status
from net.xeaf.rack.core import CoreException
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreExceptionIsPureStatusTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreException.is_pure_status()
"""
def test_is_pure_status_returns_false_by_default(self):
"""
Тест проверяет, что по умолчанию is_pure_status возвращает False
"""
# Arrange
exception = CoreException(status.HTTP_404_NOT_FOUND)
# Act
result = exception.is_pure_status()
# Assert
self.assertFalse(result)
self.assertIsInstance(result, bool)
def test_is_pure_status_always_returns_false_for_base_exception(self):
"""
Тест проверяет, что для базового класса исключения метод всегда возвращает False
"""
# Arrange
exception1 = CoreException(status.HTTP_400_BAD_REQUEST)
exception2 = CoreException(status.HTTP_500_INTERNAL_SERVER_ERROR)
exception3 = CoreException(status.HTTP_200_OK)
# Act & Assert
self.assertFalse(exception1.is_pure_status())
self.assertFalse(exception2.is_pure_status())
self.assertFalse(exception3.is_pure_status())

View File

@@ -0,0 +1,130 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreExceptionProcessMetaTests
"""
from rest_framework import status
from net.xeaf.rack.core import CoreException
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreExceptionProcessMetaTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreException._process_meta()
"""
def test_process_meta_with_meta_parameter(self):
"""
Тест проверяет обработку мета-данных при передаче параметра meta
"""
# Arrange
exception = CoreException(status.HTTP_400_BAD_REQUEST)
meta = {"key": "value", "count": 10}
# Act
exception._process_meta(meta)
# Assert
self.assertEqual(exception.meta, meta)
def test_process_meta_with_kwargs_only(self):
"""
Тест проверяет обработку дополнительных параметров при отсутствии meta
"""
# Arrange
exception = CoreException(status.HTTP_400_BAD_REQUEST)
kwargs = {"param1": "value1", "param2": "value2"}
# Act
exception._process_meta(None, **kwargs)
# Assert
self.assertEqual(exception.meta, kwargs)
def test_process_meta_with_meta_and_kwargs(self):
"""
Тест проверяет обработку мета-данных и дополнительных параметров вместе
"""
# Arrange
exception = CoreException(status.HTTP_400_BAD_REQUEST)
meta = {"existing": "data"}
kwargs = {"new": "value", "additional": 123}
# Act
exception._process_meta(meta, **kwargs)
# Assert
self.assertEqual(exception.meta, {"existing": "data", "new": "value", "additional": 123})
def test_process_meta_with_empty_meta_and_kwargs(self):
"""
Тест проверяет обработку пустых мета-данных и дополнительных параметров
"""
# Arrange
exception = CoreException(status.HTTP_400_BAD_REQUEST)
meta = {}
kwargs = {"param": "value"}
# Act
exception._process_meta(meta, **kwargs)
# Assert
self.assertEqual(exception.meta, {"param": "value"})
def test_process_meta_with_meta_none_and_no_kwargs(self):
"""
Тест проверяет обработку, когда meta равен None и kwargs отсутствуют
"""
# Arrange
exception = CoreException(status.HTTP_400_BAD_REQUEST)
# Act
exception._process_meta(None)
# Assert
self.assertIsNone(exception.meta)
def test_process_meta_with_meta_and_kwargs_overwrites_duplicate_keys(self):
"""
Тест проверяет, что kwargs перезаписывают ключи из meta при совпадении
"""
# Arrange
exception = CoreException(status.HTTP_400_BAD_REQUEST)
meta = {"key": "from_meta", "keep": "this"}
kwargs = {"key": "from_kwargs", "added": "new"}
# Act
exception._process_meta(meta, **kwargs)
# Assert
self.assertEqual(exception.meta, {"key": "from_kwargs", "keep": "this", "added": "new"})
def test_process_meta_preserves_meta_structure(self):
"""
Тест проверяет сохранение структуры мета-данных при обновлении
"""
# Arrange
exception = CoreException(status.HTTP_400_BAD_REQUEST)
meta = {"nested": {"field": "value"}, "list": [1, 2, 3]}
kwargs = {"extra": "data"}
# Act
exception._process_meta(meta, **kwargs)
# Assert
self.assertEqual(exception.meta["nested"], {"field": "value"})
self.assertEqual(exception.meta["list"], [1, 2, 3])
self.assertEqual(exception.meta["extra"], "data")

View File

@@ -0,0 +1,213 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreExceptionTests
"""
from rest_framework import status
from net.xeaf.rack.core import CoreException
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreExceptionTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreException.__init__()
"""
def test_init_with_required_parameters_only(self):
"""
Тест проверяет инициализацию исключения только с обязательным параметром status_code
"""
# Arrange
status_code = status.HTTP_404_NOT_FOUND
# Act
exception = CoreException(status_code)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertIsNone(exception.detail)
self.assertIsNone(exception.meta)
self.assertIsInstance(exception, Exception)
def test_init_with_status_code_and_detail_string(self):
"""
Тест проверяет инициализацию исключения с кодом статуса и строковым описанием
"""
# Arrange
status_code = status.HTTP_400_BAD_REQUEST
detail = "Invalid request parameters"
# Act
exception = CoreException(status_code, detail)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertIsNone(exception.meta)
def test_init_with_status_code_and_detail_dict(self):
"""
Тест проверяет инициализацию исключения с кодом статуса и деталями в виде словаря
"""
# Arrange
status_code = status.HTTP_400_BAD_REQUEST
detail = {"field": "email", "error": "Invalid email format"}
# Act
exception = CoreException(status_code, detail)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertIsNone(exception.meta)
def test_init_with_status_code_and_detail_list(self):
"""
Тест проверяет инициализацию исключения с кодом статуса и деталями в виде списка
"""
# Arrange
status_code = status.HTTP_400_BAD_REQUEST
detail = ["Field 'name' is required", "Field 'email' is required"]
# Act
exception = CoreException(status_code, detail)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertIsNone(exception.meta)
def test_init_with_meta_parameter(self):
"""
Тест проверяет инициализацию исключения с мета-данными
"""
# Arrange
status_code = status.HTTP_403_FORBIDDEN
detail = "Access denied"
meta = {"resource": "user_profile", "required_permission": "edit"}
# Act
exception = CoreException(status_code, detail, meta)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertEqual(exception.meta, meta)
def test_init_with_empty_meta(self):
"""
Тест проверяет инициализацию исключения с пустыми мета-данными
"""
# Arrange
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
detail = "Internal server error"
meta = {}
# Act
exception = CoreException(status_code, detail, meta)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertEqual(exception.meta, {})
def test_init_with_kwargs_only(self):
"""
Тест проверяет инициализацию исключения с дополнительными параметрами через kwargs
"""
# Arrange
status_code = status.HTTP_409_CONFLICT
detail = "Conflict"
kwargs = {"conflict_id": 123, "conflict_type": "duplicate"}
# Act
exception = CoreException(status_code, detail, **kwargs)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertEqual(exception.meta, kwargs)
def test_init_with_meta_and_kwargs(self):
"""
Тест проверяет инициализацию исключения с мета-данными и дополнительными параметрами
"""
# Arrange
status_code = status.HTTP_429_TOO_MANY_REQUESTS
detail = "Too many requests"
meta = {"retry_after": 60}
kwargs = {"limit": 100, "window": 60}
# Act
exception = CoreException(status_code, detail, meta, **kwargs)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertEqual(exception.meta, {"retry_after": 60, "limit": 100, "window": 60})
def test_init_with_meta_none_and_kwargs(self):
"""
Тест проверяет инициализацию исключения с None в качестве мета и дополнительными параметрами
"""
# Arrange
status_code = status.HTTP_401_UNAUTHORIZED
detail = "Unauthorized"
kwargs = {"auth_type": "Bearer", "expired": True}
# Act
exception = CoreException(status_code, detail, None, **kwargs)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertEqual(exception.meta, kwargs)
def test_init_with_meta_empty_and_kwargs(self):
"""
Тест проверяет инициализацию исключения с пустыми мета-данными и дополнительными параметрами
"""
# Arrange
status_code = status.HTTP_402_PAYMENT_REQUIRED
detail = "Payment required"
meta = {}
kwargs = {"payment_id": 456, "amount": 99.99}
# Act
exception = CoreException(status_code, detail, meta, **kwargs)
# Assert
self.assertEqual(exception.status_code, status_code)
self.assertEqual(exception.detail, detail)
self.assertEqual(exception.meta, {"payment_id": 456, "amount": 99.99})
def test_init_creates_exception_subclass(self):
"""
Тест проверяет, что CoreException является подклассом Exception
"""
# Arrange
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
# Act
exception = CoreException(status_code)
# Assert
self.assertIsInstance(exception, Exception)
self.assertTrue(issubclass(CoreException, Exception))

View File

@@ -0,0 +1,307 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CoreResponseTests
"""
from rest_framework import status
from rest_framework.response import Response
from net.xeaf.rack.core import CoreResponse
from net.xeaf.rack.core.testing import CoreSimpleTestCase
class CoreResponseTests(CoreSimpleTestCase):
"""
Тестовый класс для CoreResponse.__init__()
"""
def test_init_with_success_status_and_data(self):
"""
Тест проверяет инициализацию ответа с успешным статусом и данными
"""
# Arrange
internal_status = status.HTTP_200_OK
external_status = status.HTTP_200_OK
data = {"key": "value", "list": [1, 2, 3]}
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertIn("timestamp", response.data)
self.assertEqual(response.data["data"], data)
self.assertNotIn("detail", response.data)
self.assertNotIn("meta", response.data)
def test_init_with_success_status_and_string_data(self):
"""
Тест проверяет инициализацию ответа с успешным статусом и строковыми данными
"""
# Arrange
internal_status = status.HTTP_201_CREATED
external_status = status.HTTP_201_CREATED
data = "Operation completed successfully"
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["data"], data)
self.assertNotIn("detail", response.data)
def test_init_with_success_status_and_bool_data(self):
"""
Тест проверяет инициализацию ответа с успешным статусом и булевыми данными
"""
# Arrange
internal_status = status.HTTP_200_OK
external_status = status.HTTP_200_OK
data = True
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["data"], data)
self.assertNotIn("detail", response.data)
def test_init_with_success_status_and_none_data(self):
"""
Тест проверяет инициализацию ответа с успешным статусом и None в качестве данных
"""
# Arrange
internal_status = status.HTTP_204_NO_CONTENT
external_status = status.HTTP_204_NO_CONTENT
data = None
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertNotIn("data", response.data)
self.assertNotIn("detail", response.data)
def test_init_with_error_status_and_detail(self):
"""
Тест проверяет инициализацию ответа с ошибочным статусом и деталями ошибки
"""
# Arrange
internal_status = status.HTTP_400_BAD_REQUEST
external_status = status.HTTP_400_BAD_REQUEST
data = "Invalid request parameters"
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["detail"], data)
self.assertNotIn("data", response.data)
def test_init_with_error_status_and_dict_detail(self):
"""
Тест проверяет инициализацию ответа с ошибочным статусом и деталями в виде словаря
"""
# Arrange
internal_status = status.HTTP_422_UNPROCESSABLE_ENTITY
external_status = status.HTTP_422_UNPROCESSABLE_ENTITY
data = {"field": "email", "error": "Invalid email format"}
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["detail"], data)
self.assertNotIn("data", response.data)
def test_init_with_server_error_status_and_detail(self):
"""
Тест проверяет инициализацию ответа с серверной ошибкой и деталями
"""
# Arrange
internal_status = status.HTTP_500_INTERNAL_SERVER_ERROR
external_status = status.HTTP_500_INTERNAL_SERVER_ERROR
data = "Internal server error occurred"
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["detail"], data)
self.assertNotIn("data", response.data)
def test_init_with_meta_data(self):
"""
Тест проверяет инициализацию ответа с дополнительными мета-данными
"""
# Arrange
internal_status = status.HTTP_200_OK
external_status = status.HTTP_200_OK
data = {"id": 123}
meta = {"page": 1, "total": 100, "per_page": 10}
# Act
response = CoreResponse(internal_status, external_status, data, meta)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["data"], data)
self.assertEqual(response.data["meta"], meta)
def test_init_with_error_status_and_meta(self):
"""
Тест проверяет инициализацию ответа с ошибочным статусом и мета-данными
"""
# Arrange
internal_status = status.HTTP_404_NOT_FOUND
external_status = status.HTTP_404_NOT_FOUND
data = "Resource not found"
meta = {"resource_type": "user", "requested_id": 999}
# Act
response = CoreResponse(internal_status, external_status, data, meta)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["detail"], data)
self.assertEqual(response.data["meta"], meta)
self.assertNotIn("data", response.data)
def test_init_with_empty_meta(self):
"""
Тест проверяет инициализацию ответа с пустыми мета-данными
"""
# Arrange
internal_status = status.HTTP_200_OK
external_status = status.HTTP_200_OK
data = {"id": 123}
meta = {}
# Act
response = CoreResponse(internal_status, external_status, data, meta)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["data"], data)
self.assertEqual(response.data["meta"], {})
self.assertNotIn("detail", response.data)
def test_init_timestamp_is_integer(self):
"""
Тест проверяет, что timestamp является целым числом
"""
# Arrange
internal_status = status.HTTP_200_OK
external_status = status.HTTP_200_OK
data = "test"
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertIn("timestamp", response.data)
self.assertIsInstance(response.data["timestamp"], int)
self.assertGreater(response.data["timestamp"], 0)
def test_init_with_different_external_status(self):
"""
Тест проверяет инициализацию ответа с отличающимися внутренним и внешним статусами
"""
# Arrange
internal_status = status.HTTP_200_OK
external_status = status.HTTP_201_CREATED
data = {"created": True}
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["data"], data)
self.assertNotEqual(response.status_code, internal_status)
def test_init_with_boundary_status_values(self):
"""
Тест проверяет инициализацию ответа с граничными значениями статусов
"""
# Arrange - тестируем границу между успехом и ошибкой (400)
internal_status = 399
external_status = 399
data = "Redirect"
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert - на уровне 399 это все еще считается успешным (менее 400)
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["data"], data)
self.assertNotIn("detail", response.data)
# Arrange - тестируем границу ошибок (400)
internal_status = status.HTTP_400_BAD_REQUEST
external_status = status.HTTP_400_BAD_REQUEST
data = "Bad Request"
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert - на уровне 400 это считается ошибкой
self.assertEqual(response.status_code, external_status)
self.assertEqual(response.data["status"], internal_status)
self.assertEqual(response.data["detail"], data)
self.assertNotIn("data", response.data)
def test_init_inherits_from_drf_response(self):
"""
Тест проверяет, что CoreResponse наследуется от DRF Response
"""
# Arrange
internal_status = status.HTTP_200_OK
external_status = status.HTTP_200_OK
data = "test"
# Act
response = CoreResponse(internal_status, external_status, data)
# Assert
self.assertIsInstance(response, Response)
self.assertTrue(hasattr(response, "data"))
self.assertTrue(hasattr(response, "status_code"))
self.assertTrue(hasattr(response, "render"))

View File

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

View File

@@ -0,0 +1,20 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет описания классов тестов классов работ с настройками
"""
from .environment_check_initialization_tests import EnvironmentCheckInitializationTests
from .environment_env_name_tests import EnvironmentEnvNameTests
from .environment_env_tests import EnvironmentEnvTests
from .environment_get_bool_tests import EnvironmentGetBoolTests
from .environment_get_int_tests import EnvironmentGetIntTests
from .environment_get_str_as_dir_tests import EnvironmentGetStrAsDirTests
from .environment_get_str_as_file_tests import EnvironmentGetStrAsFileTests
from .environment_get_str_as_list_tests import EnvironmentGetStrAsListTests
from .environment_get_str_tests import EnvironmentGetStrTests
from .environment_initialize_tests import EnvironmentInitializeTests

View File

@@ -0,0 +1,52 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса EnvironmentCheckInitializationTests
"""
from django.core.exceptions import ImproperlyConfigured
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from net.xeaf.rack.utils.settings import Environment
class EnvironmentCheckInitializationTests(CoreSimpleTestCase):
"""
Тестовый класс для Environment._check_initialization()
"""
def setUp(self):
"""
Подготовка тестовой среды
"""
super().setUp()
Environment._is_initialized = False
def test_check_initialization_raises_error_when_not_initialized(self):
"""
Тест проверяет выбрасывание ошибки при отсутствии инициализации
"""
# Act & Assert
with self.assertRaises(ImproperlyConfigured) as context:
Environment._check_initialization()
self.assertIn("not initialized", str(context.exception))
def test_check_initialization_does_not_raise_when_initialized(self):
"""
Тест проверяет отсутствие ошибки при инициализации
"""
# Arrange
Environment._is_initialized = True
# Act & Assert
try:
Environment._check_initialization()
except ImproperlyConfigured:
self.fail("_check_initialization raised ImproperlyConfigured unexpectedly")

View File

@@ -0,0 +1,82 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса EnvironmentEnvNameTests
"""
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from net.xeaf.rack.utils.settings import Environment
class EnvironmentEnvNameTests(CoreSimpleTestCase):
"""
Тестовый класс для Environment._env_name()
"""
def setUp(self):
"""
Подготовка тестовой среды
"""
super().setUp()
Environment._is_initialized = True
def test_env_name_adds_prefix(self):
"""
Тест проверяет добавление префикса к имени переменной
"""
# Arrange
Environment._env_prefix = "PREFIX_"
# Act
result = Environment._env_name("VAR_NAME")
# Assert
self.assertEqual(result, "PREFIX_VAR_NAME")
def test_env_name_with_empty_prefix(self):
"""
Тест проверяет обработку пустого префикса
"""
# Arrange
Environment._env_prefix = ""
# Act
result = Environment._env_name("VAR_NAME")
# Assert
self.assertEqual(result, "VAR_NAME")
def test_env_name_handles_empty_name(self):
"""
Тест проверяет обработку пустого имени переменной
"""
# Arrange
Environment._env_prefix = "PREFIX_"
# Act
result = Environment._env_name("")
# Assert
self.assertEqual(result, "PREFIX_")
def test_env_name_preserves_case(self):
"""
Тест проверяет сохранение регистра имени переменной
"""
# Arrange
Environment._env_prefix = "PREFIX_"
# Act
result = Environment._env_name("ApiKey")
# Assert
self.assertEqual(result, "PREFIX_ApiKey")

View File

@@ -0,0 +1,55 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса EnvironmentEnvTests
"""
from unittest.mock import Mock
from django.core.exceptions import ImproperlyConfigured
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from net.xeaf.rack.utils.settings import Environment
class EnvironmentEnvTests(CoreSimpleTestCase):
"""
Тестовый класс для Environment.env()
"""
def setUp(self):
"""
Подготовка тестовой среды: сброс состояния Environment
"""
super().setUp()
Environment._is_initialized = True
def test_env_returns_env_object_when_initialized(self):
"""
Тест проверяет, что env() возвращает объект Env при инициализации
"""
# Arrange
mock_env = Mock()
Environment._env = mock_env
# Act
result = Environment.env()
# Assert
self.assertEqual(result, mock_env)
def test_env_raises_error_when_not_initialized(self):
"""
Тест проверяет, что env() выбрасывает ошибку при отсутствии инициализации
"""
# Arrange
Environment._is_initialized = False
# Act & Assert
with self.assertRaises(ImproperlyConfigured) as context:
Environment.env()
self.assertIn("not initialized", str(context.exception))

View File

@@ -0,0 +1,68 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса EnvironmentGetBoolTests
"""
from unittest.mock import Mock
from unittest.mock import patch
from environ import Env
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from net.xeaf.rack.utils.settings import Environment
class EnvironmentGetBoolTests(CoreSimpleTestCase):
"""
Тестовый класс для Environment.get_bool()
"""
def setUp(self):
"""
Подготовка тестовой среды
"""
super().setUp()
Environment._is_initialized = True
Environment._env_prefix = "APP_"
@patch('net.xeaf.rack.utils.settings.Environment.env')
def test_get_bool_returns_boolean_value(self, mock_env):
"""
Тест проверяет получение логического значения переменной окружения
"""
# Arrange
mock_env_instance = Mock()
mock_env_instance.bool.return_value = True
mock_env.return_value = mock_env_instance
# Act
result = Environment.get_bool("DEBUG")
# Assert
self.assertTrue(result)
mock_env_instance.bool.assert_called_once_with("APP_DEBUG", Env.NOTSET)
@patch('net.xeaf.rack.utils.settings.Environment.env')
def test_get_bool_with_default_value(self, mock_env):
"""
Тест проверяет получение логического значения с указанием значения по умолчанию
"""
# Arrange
mock_env_instance = Mock()
mock_env_instance.bool.return_value = False
mock_env.return_value = mock_env_instance
# Act
result = Environment.get_bool("DEBUG", False)
# Assert
self.assertFalse(result)
mock_env_instance.bool.assert_called_once_with("APP_DEBUG", False)

View File

@@ -0,0 +1,83 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса EnvironmentGetIntTests
"""
from unittest.mock import Mock
from unittest.mock import patch
from environ import Env
from net.xeaf.rack.core.testing import CoreSimpleTestCase
from net.xeaf.rack.utils.settings import Environment
class EnvironmentGetIntTests(CoreSimpleTestCase):
"""
Тестовый класс для Environment.get_int()
"""
def setUp(self):
"""
Подготовка тестовой среды
"""
super().setUp()
Environment._is_initialized = True
Environment._env_prefix = "APP_"
@patch('net.xeaf.rack.utils.settings.Environment.env')
def test_get_int_returns_integer_value(self, mock_env):
"""
Тест проверяет получение целочисленного значения переменной окружения
"""
# Arrange
mock_env_instance = Mock()
mock_env_instance.int.return_value = 42
mock_env.return_value = mock_env_instance
# Act
result = Environment.get_int("PORT")
# Assert
self.assertEqual(result, 42)
mock_env_instance.int.assert_called_once_with("APP_PORT", Env.NOTSET)
@patch('net.xeaf.rack.utils.settings.Environment.env')
def test_get_int_with_default_value(self, mock_env):
"""
Тест проверяет получение целочисленного значения с указанием значения по умолчанию
"""
# Arrange
mock_env_instance = Mock()
mock_env_instance.int.return_value = 8080
mock_env.return_value = mock_env_instance
# Act
result = Environment.get_int("PORT", 8080)
# Assert
self.assertEqual(result, 8080)
mock_env_instance.int.assert_called_once_with("APP_PORT", 8080)
@patch('net.xeaf.rack.utils.settings.Environment.env')
def test_get_int_raises_error_for_invalid_value(self, mock_env):
"""
Тест проверяет выбрасывание ошибки при невалидном целочисленном значении
"""
# Arrange
mock_env_instance = Mock()
mock_env_instance.int.side_effect = ValueError("Invalid integer")
mock_env.return_value = mock_env_instance
# Act & Assert
with self.assertRaises(ValueError) as context:
Environment.get_int("PORT")
self.assertIn("Invalid integer", str(context.exception))

Some files were not shown because too many files have changed in this diff Show More