Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
304fa18bce
|
|||
|
781e885ffb
|
|||
|
05ce5b7cf4
|
|||
|
2d58667e49
|
|||
|
2d0911a574
|
|||
|
b923cac032
|
|||
|
785bf1ba29
|
|||
|
a25b34ab3f
|
|||
|
9939aa5799
|
|||
|
9e71bb6e29
|
|||
|
da3f3aa703
|
|||
|
e40d5a4159
|
|||
|
a6c9db1a71
|
|||
|
7c13582fdb
|
|||
|
1d95a70282
|
|||
|
9dbbefedd3
|
|||
|
7c0f32d1fc
|
|||
|
a8d36b59b4
|
|||
|
7f87794fcc
|
|||
|
02087aa80a
|
|||
|
a5a29b03b6
|
|||
|
a95793c3df
|
|||
|
f0a6e4d97d
|
|||
|
cc57591e66
|
|||
|
87a4f06d40
|
|||
|
ba8da9c8c9
|
|||
|
32ac575178
|
|||
|
7428bc251f
|
|||
|
ac94627356
|
|||
|
3d52df7687
|
|||
|
3324dbe9fb
|
|||
|
11b332be2d
|
|||
|
579d014dde
|
|||
|
aeebd28c52
|
|||
|
0c1c2bd61c
|
|||
|
b68f17f069
|
|||
|
3d1e6b47bd
|
|||
|
63537f24a0
|
|||
|
b713a2124b
|
|||
|
4ce8a1ba58
|
|||
|
9a9f25c69e
|
|||
|
3af5f9988a
|
|||
|
5330137041
|
|||
|
54d717369a
|
|||
|
5c39031f04
|
|||
|
c0cefe2f32
|
|||
|
f0db1ba9b5
|
|||
|
c5e4c88588
|
|||
|
c9a75b3370
|
|||
|
1acd7c0029
|
|||
|
9c13222d0d
|
|||
|
b2e15511c1
|
|||
|
164598294a
|
|||
|
b816a7766a
|
|||
|
7dfd4a242b
|
|||
|
c90abe11f8
|
|||
|
3fba091cbc
|
|||
|
240ae2f448
|
|||
|
760480536c
|
|||
|
5d30e8cbaf
|
|||
|
b4c505048d
|
|||
|
31a13c7ccc
|
|||
|
ef09fb4de3
|
|||
|
0370f20efd
|
|||
|
15cc114656
|
|||
|
8a86b517fc
|
17
.coveragerc
Normal file
17
.coveragerc
Normal file
@@ -0,0 +1,17 @@
|
||||
[run]
|
||||
# Укажи папки со своей бизнес-логикой
|
||||
source = net
|
||||
|
||||
# Игнорируем миграции, тесты и системные файлы Django
|
||||
omit =
|
||||
*/migrations/*
|
||||
*/tests/*
|
||||
*/testing/*
|
||||
*/configs/*
|
||||
*/apps.py
|
||||
manage.py
|
||||
|
||||
[report]
|
||||
# Принудительно выводить номера строк, которые НЕ покрыты тестами
|
||||
show_missing = False
|
||||
|
||||
@@ -10,9 +10,7 @@ trim_trailing_whitespace = true
|
||||
|
||||
[*.html]
|
||||
max_line_length = 240
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
|
||||
17
.rackcoveragerc
Normal file
17
.rackcoveragerc
Normal 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__
|
||||
74
THIRDPATY.md
74
THIRDPATY.md
@@ -10,42 +10,42 @@
|
||||
|
||||
## Таблица. Перечень сторонних библиотек и компонентов
|
||||
|
||||
| № п/п | Наименование компонента | Версия | Тип лицензии | Функциональное назначение компонента |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Django | 6.0.7 | BSD-3-Clause | Основной веб-фреймворк архитектуры ПО |
|
||||
| 2 | djangorestframework | 3.17.1 | BSD-3-Clause | Построение REST API интерфейсов |
|
||||
| 3 | asgiref | 3.11.1 | BSD License | Поддержка асинхронных интерфейсов веб-сервера |
|
||||
| 4 | boto3 | 1.43.44 | Apache-2.0 | Интеграция и управление облачным объектным хранилищем |
|
||||
| 5 | botocore | 1.43.44 | Apache-2.0 | Ядро низкоуровневых запросов к облачной инфраструктуре |
|
||||
| 6 | s3transfer | 0.19.0 | Apache-2.0 | Управление многопоточной загрузкой файлов в хранилище |
|
||||
| 7 | django-storages | 1.14.6 | BSD License | Подключение внешних хранилищ данных к медиа-файлам |
|
||||
| 8 | concurrent-log-handler | 0.9.29 | Apache-2.0 | Безопасное многопоточное логирование процессов |
|
||||
| 9 | django-cors-headers | 4.9.0 | MIT | Регулирование правил кросс-доменных запросов (CORS) |
|
||||
| 10 | django-environ | 0.14.0 | MIT | Безопасная конфигурация проекта через переменные окружения |
|
||||
| 11 | django-split-settings | 1.3.2 | BSD License | Модульное разделение настроек проекта по окружениям |
|
||||
| 12 | split-settings | 1.0.0 | BSD License | Вспомогательная утилита разделения конфигурационных файлов |
|
||||
| 13 | gunicorn | 26.0.0 | MIT | WSGI-сервер для развертывания веб-приложения |
|
||||
| 14 | jmespath | 1.1.0 | MIT | Декларативный синтаксис для фильтрации JSON-данных |
|
||||
| 15 | packaging | 26.2 | BSD-2-Clause | Парсинг и валидация версий пакетов зависимостей |
|
||||
| 16 | python-dateutil | 2.9.0.post0 | Apache-2.0 | Расширенная работа с форматами даты и часовыми поясами |
|
||||
| 17 | pillow | 12.3.0 | MIT-CMU | Обработка, оптимизация и валидация графических файлов |
|
||||
| 18 | portalocker | 3.2.0 | BSD-3-Clause | Кроссплатформенная блокировка файлов при записи |
|
||||
| 19 | psycopg | 3.3.4 | LGPL-3.0-only | См. Особые примечания (Драйвер СУБД) |
|
||||
| 20 | psycopg-binary | 3.3.4 | LGPL-3.0-only | См. Особые примечания (Драйвер СУБД) |
|
||||
| 21 | pywin32 | 312 | PSF License | Обеспечение совместимости со средой разработки Windows |
|
||||
| 22 | redis (Python-пакет) | 8.0.1 | MIT | Клиентская библиотека сетевого протокола RESP |
|
||||
| 23 | KeyDB (Инфраструктура) | любая | BSD-3-Clause | База данных в ОЗУ для кэширования (вместо ПО Redis) |
|
||||
| 24 | six | 1.17.0 | MIT | Утилита совместимости кодовой базы |
|
||||
| 25 | sqlparse | 0.5.5 | BSD License | Форматирование и невалидирующий парсинг SQL-запросов |
|
||||
| 26 | typing_extensions | 4.16.0 | PSF-2.0 | Поддержка продвинутой статической типизации кода |
|
||||
| 27 | tzdata | 2026.2 | Apache-2.0 | Системный провайдер актуальной базы часовых поясов |
|
||||
| 28 | urllib3 | 2.7.0 | MIT | Клиент HTTP-запросов к внешним сервисам |
|
||||
| 29 | djangorestframework-camel-case | 1.4.2 | BSD License | Автоматическая конвертация стилей JSON-ключей в API |
|
||||
| 30 | djangorestframework-dataclasses | 1.4.0 | BSD License | Интеграция структур данных Dataclasses в сериализаторы |
|
||||
| 31 | drf-rack | 0.1.0 | MIT | Дополнительные инструменты профилирования REST API |
|
||||
| 32 | django-stubs / ext | 6.0.6 | MIT | Аннотации типов для статического анализатора кода |
|
||||
| 33 | djangorestframework-stubs | 317.0 | MIT | Аннотации типов для валидации API-слоя |
|
||||
| 34 | types-PyYAML | 6.0.12 | Apache-2.0 | Аннотации типов конфигурационных файлов YAML |
|
||||
| № п/п | Наименование компонента | Версия | Тип лицензии | Функциональное назначение компонента |
|
||||
|------:|---------------------------------|-------------|---------------|------------------------------------------------------------|
|
||||
| 1 | Django | 6.0.7 | BSD-3-Clause | Основной веб-фреймворк архитектуры ПО |
|
||||
| 2 | djangorestframework | 3.17.1 | BSD-3-Clause | Построение REST API интерфейсов |
|
||||
| 3 | asgiref | 3.11.1 | BSD License | Поддержка асинхронных интерфейсов веб-сервера |
|
||||
| 4 | boto3 | 1.43.44 | Apache-2.0 | Интеграция и управление облачным объектным хранилищем |
|
||||
| 5 | botocore | 1.43.44 | Apache-2.0 | Ядро низкоуровневых запросов к облачной инфраструктуре |
|
||||
| 6 | s3transfer | 0.19.0 | Apache-2.0 | Управление многопоточной загрузкой файлов в хранилище |
|
||||
| 7 | django-storages | 1.14.6 | BSD License | Подключение внешних хранилищ данных к медиа-файлам |
|
||||
| 8 | concurrent-log-handler | 0.9.29 | Apache-2.0 | Безопасное многопоточное логирование процессов |
|
||||
| 9 | django-cors-headers | 4.9.0 | MIT | Регулирование правил кросс-доменных запросов (CORS) |
|
||||
| 10 | django-environ | 0.14.0 | MIT | Безопасная конфигурация проекта через переменные окружения |
|
||||
| 11 | django-split-settings | 1.3.2 | BSD License | Модульное разделение настроек проекта по окружениям |
|
||||
| 12 | split-settings | 1.0.0 | BSD License | Вспомогательная утилита разделения конфигурационных файлов |
|
||||
| 13 | gunicorn | 26.0.0 | MIT | WSGI-сервер для развертывания веб-приложения |
|
||||
| 14 | jmespath | 1.1.0 | MIT | Декларативный синтаксис для фильтрации JSON-данных |
|
||||
| 15 | packaging | 26.2 | BSD-2-Clause | Парсинг и валидация версий пакетов зависимостей |
|
||||
| 16 | python-dateutil | 2.9.0.post0 | Apache-2.0 | Расширенная работа с форматами даты и часовыми поясами |
|
||||
| 17 | pillow | 12.3.0 | MIT-CMU | Обработка, оптимизация и валидация графических файлов |
|
||||
| 18 | portalocker | 3.2.0 | BSD-3-Clause | Кроссплатформенная блокировка файлов при записи |
|
||||
| 19 | psycopg | 3.3.4 | LGPL-3.0-only | См. Особые примечания (Драйвер СУБД) |
|
||||
| 20 | psycopg-binary | 3.3.4 | LGPL-3.0-only | См. Особые примечания (Драйвер СУБД) |
|
||||
| 21 | pywin32 | 312 | PSF License | Обеспечение совместимости со средой разработки Windows |
|
||||
| 22 | redis (Python-пакет) | 8.0.1 | MIT | Клиентская библиотека сетевого протокола RESP |
|
||||
| 23 | KeyDB (Инфраструктура) | любая | BSD-3-Clause | База данных в ОЗУ для кэширования (вместо ПО Redis) |
|
||||
| 24 | six | 1.17.0 | MIT | Утилита совместимости кодовой базы |
|
||||
| 25 | sqlparse | 0.5.5 | BSD License | Форматирование и невалидирующий парсинг SQL-запросов |
|
||||
| 26 | typing_extensions | 4.16.0 | PSF-2.0 | Поддержка продвинутой статической типизации кода |
|
||||
| 27 | tzdata | 2026.2 | Apache-2.0 | Системный провайдер актуальной базы часовых поясов |
|
||||
| 28 | urllib3 | 2.7.0 | MIT | Клиент HTTP-запросов к внешним сервисам |
|
||||
| 29 | djangorestframework-camel-case | 1.4.2 | BSD License | Автоматическая конвертация стилей JSON-ключей в API |
|
||||
| 30 | djangorestframework-dataclasses | 1.4.0 | BSD License | Интеграция структур данных Dataclasses в сериализаторы |
|
||||
| 31 | drf-rack | 0.1.0 | MIT | Дополнительные инструменты профилирования REST API |
|
||||
| 32 | django-stubs / ext | 6.0.6 | MIT | Аннотации типов для статического анализатора кода |
|
||||
| 33 | djangorestframework-stubs | 317.0 | MIT | Аннотации типов для валидации API-слоя |
|
||||
| 34 | types-PyYAML | 6.0.12 | Apache-2.0 | Аннотации типов конфигурационных файлов YAML |
|
||||
|
||||
## Особые примечания по компонентам с лицензией LGPL (psycopg / psycopg-binary)
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
(посредством штатных инструкций импорта).
|
||||
|
||||
Исходный текст самих компонентов модификации не подвергается, в состав разрабатываемого ПО статически
|
||||
не интегрируется и загружается изолированно из официального публичного репозитория.В соответствии с
|
||||
не интегрируется и загружается изолированно из официального публичного репозитория. В соответствии с
|
||||
условиями Раздела 4 лицензии GNU Lesser General Public License (LGPLv3), данная схема динамического
|
||||
связывания является правомерной, не распространяет действие условий указанной лицензии на разработанное ПО,
|
||||
не накладывает обязательств по предоставлению исходного текста основного программного продукта и не
|
||||
|
||||
92
docker-compose.env
Normal file
92
docker-compose.env
Normal 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
121
docker-compose.yml
Normal 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
22
manage.py
Normal 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
9
net/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# DRF Rack
|
||||
# Библиотека классов расширений для Django REST Framework
|
||||
#
|
||||
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||
# Все права защищены. Лицензия: MIT
|
||||
|
||||
"""
|
||||
Корневой пакет для всех классов библиотеки
|
||||
"""
|
||||
9
net/xeaf/__init__.py
Normal file
9
net/xeaf/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# DRF Rack
|
||||
# Библиотека классов расширений для Django REST Framework
|
||||
#
|
||||
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||
# Все права защищены. Лицензия: MIT
|
||||
|
||||
"""
|
||||
Общий пакет для всех классов библиотеки
|
||||
"""
|
||||
9
net/xeaf/rack/__init__.py
Normal file
9
net/xeaf/rack/__init__.py
Normal 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
21
net/xeaf/rack/apps.py
Normal 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"
|
||||
18
net/xeaf/rack/core/__init__.py
Normal file
18
net/xeaf/rack/core/__init__.py
Normal 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
|
||||
81
net/xeaf/rack/core/core_command.py
Normal file
81
net/xeaf/rack/core/core_command.py
Normal 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}")
|
||||
81
net/xeaf/rack/core/core_enum.py
Normal file
81
net/xeaf/rack/core/core_enum.py
Normal 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.")
|
||||
|
||||
68
net/xeaf/rack/core/core_exception.py
Normal file
68
net/xeaf/rack/core/core_exception.py
Normal 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)
|
||||
26
net/xeaf/rack/core/core_model.py
Normal file
26
net/xeaf/rack/core/core_model.py
Normal 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
|
||||
27
net/xeaf/rack/core/core_persistent_model.py
Normal file
27
net/xeaf/rack/core/core_persistent_model.py
Normal 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
|
||||
51
net/xeaf/rack/core/core_response.py
Normal file
51
net/xeaf/rack/core/core_response.py
Normal 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
|
||||
)
|
||||
12
net/xeaf/rack/core/testing/__init__.py
Normal file
12
net/xeaf/rack/core/testing/__init__.py
Normal 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
|
||||
27
net/xeaf/rack/core/testing/core_simple_test_case.py
Normal file
27
net/xeaf/rack/core/testing/core_simple_test_case.py
Normal 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()
|
||||
80
net/xeaf/rack/core/testing/core_user_story_test_case.py
Normal file
80
net/xeaf/rack/core/testing/core_user_story_test_case.py
Normal 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
|
||||
)
|
||||
164
net/xeaf/rack/core/testing/request_test_case_mixin.py
Normal file
164
net/xeaf/rack/core/testing/request_test_case_mixin.py
Normal 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
|
||||
16
net/xeaf/rack/core/views/__init__.py
Normal file
16
net/xeaf/rack/core/views/__init__.py
Normal 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
|
||||
296
net/xeaf/rack/core/views/core_view_set.py
Normal file
296
net/xeaf/rack/core/views/core_view_set.py
Normal 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))]
|
||||
92
net/xeaf/rack/core/views/view_set_create_mixin.py
Normal file
92
net/xeaf/rack/core/views/view_set_create_mixin.py
Normal 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)
|
||||
95
net/xeaf/rack/core/views/view_set_destroy_mixin.py
Normal file
95
net/xeaf/rack/core/views/view_set_destroy_mixin.py
Normal 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)
|
||||
85
net/xeaf/rack/core/views/view_set_list_mixin.py
Normal file
85
net/xeaf/rack/core/views/view_set_list_mixin.py
Normal 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))
|
||||
88
net/xeaf/rack/core/views/view_set_retrieve_mixin.py
Normal file
88
net/xeaf/rack/core/views/view_set_retrieve_mixin.py
Normal 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)
|
||||
107
net/xeaf/rack/core/views/view_set_update_mixin.py
Normal file
107
net/xeaf/rack/core/views/view_set_update_mixin.py
Normal 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)
|
||||
14
net/xeaf/rack/enums/__init__.py
Normal file
14
net/xeaf/rack/enums/__init__.py
Normal 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
|
||||
42
net/xeaf/rack/enums/auth_reject_reson.py
Normal file
42
net/xeaf/rack/enums/auth_reject_reson.py
Normal 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 ""
|
||||
42
net/xeaf/rack/enums/http_method.py
Normal file
42
net/xeaf/rack/enums/http_method.py
Normal 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'
|
||||
""" Получение возможных действий """
|
||||
|
||||
39
net/xeaf/rack/enums/ui_language.py
Normal file
39
net/xeaf/rack/enums/ui_language.py
Normal 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}")
|
||||
49
net/xeaf/rack/enums/view_set_action.py
Normal file
49
net/xeaf/rack/enums/view_set_action.py
Normal 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()}"
|
||||
9
net/xeaf/rack/management/__init__.py
Normal file
9
net/xeaf/rack/management/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# DRF Rack
|
||||
# Библиотека классов расширений для Django REST Framework
|
||||
#
|
||||
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||
# Все права защищены. Лицензия: MIT
|
||||
|
||||
"""
|
||||
Пакет классов управления
|
||||
"""
|
||||
10
net/xeaf/rack/management/commands/__init__.py
Normal file
10
net/xeaf/rack/management/commands/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# DRF Rack
|
||||
# Библиотека классов расширений для Django REST Framework
|
||||
#
|
||||
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||
# Все права защищены. Лицензия: MIT
|
||||
|
||||
"""
|
||||
Пакет классов команд управления
|
||||
"""
|
||||
|
||||
405
net/xeaf/rack/management/commands/rack_coverage.py
Normal file
405
net/xeaf/rack/management/commands/rack_coverage.py
Normal 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 {}
|
||||
44
net/xeaf/rack/management/commands/rack_test_database.py
Normal file
44
net/xeaf/rack/management/commands/rack_test_database.py
Normal 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."))
|
||||
33
net/xeaf/rack/management/commands/rack_wipe_database.py
Normal file
33
net/xeaf/rack/management/commands/rack_wipe_database.py
Normal 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."))
|
||||
15
net/xeaf/rack/managers/__init__.py
Normal file
15
net/xeaf/rack/managers/__init__.py
Normal 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
|
||||
68
net/xeaf/rack/managers/account_manager.py
Normal file
68
net/xeaf/rack/managers/account_manager.py
Normal 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)
|
||||
76
net/xeaf/rack/managers/crypto_manager.py
Normal file
76
net/xeaf/rack/managers/crypto_manager.py
Normal 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)
|
||||
126
net/xeaf/rack/managers/locale_manager.py
Normal file
126
net/xeaf/rack/managers/locale_manager.py
Normal 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)
|
||||
120
net/xeaf/rack/managers/request_manager.py
Normal file
120
net/xeaf/rack/managers/request_manager.py
Normal 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
|
||||
}
|
||||
177
net/xeaf/rack/managers/session_manager.py
Normal file
177
net/xeaf/rack/managers/session_manager.py
Normal 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)
|
||||
14
net/xeaf/rack/middleware/__init__.py
Normal file
14
net/xeaf/rack/middleware/__init__.py
Normal 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
|
||||
|
||||
136
net/xeaf/rack/middleware/custom_exception_handler.py
Normal file
136
net/xeaf/rack/middleware/custom_exception_handler.py
Normal 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
|
||||
217
net/xeaf/rack/middleware/custom_logging_middleware.py
Normal file
217
net/xeaf/rack/middleware/custom_logging_middleware.py
Normal 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
|
||||
75
net/xeaf/rack/middleware/expiring_token_authentication.py
Normal file
75
net/xeaf/rack/middleware/expiring_token_authentication.py
Normal 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
|
||||
84
net/xeaf/rack/migrations/0001_initial.py
Normal file
84
net/xeaf/rack/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
20
net/xeaf/rack/migrations/0002_alter_sessionmodel_account.py
Normal file
20
net/xeaf/rack/migrations/0002_alter_sessionmodel_account.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
9
net/xeaf/rack/migrations/__init__.py
Normal file
9
net/xeaf/rack/migrations/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# DRF Rack
|
||||
# Библиотека классов расширений для Django REST Framework
|
||||
#
|
||||
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||
# Все права защищены. Лицензия: MIT
|
||||
|
||||
"""
|
||||
Пакет классов миграций
|
||||
"""
|
||||
12
net/xeaf/rack/models/__init__.py
Normal file
12
net/xeaf/rack/models/__init__.py
Normal 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
|
||||
116
net/xeaf/rack/models/account_model.py
Normal file
116
net/xeaf/rack/models/account_model.py
Normal 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")
|
||||
67
net/xeaf/rack/models/journal_model.py
Normal file
67
net/xeaf/rack/models/journal_model.py
Normal 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")
|
||||
17
net/xeaf/rack/models/mixins/__init__.py
Normal file
17
net/xeaf/rack/models/mixins/__init__.py
Normal 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
|
||||
34
net/xeaf/rack/models/mixins/created_at_mixin.py
Normal file
34
net/xeaf/rack/models/mixins/created_at_mixin.py
Normal 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
|
||||
66
net/xeaf/rack/models/mixins/deleted_at_mixin.py
Normal file
66
net/xeaf/rack/models/mixins/deleted_at_mixin.py
Normal 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
|
||||
34
net/xeaf/rack/models/mixins/description_mixin.py
Normal file
34
net/xeaf/rack/models/mixins/description_mixin.py
Normal 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
|
||||
89
net/xeaf/rack/models/mixins/expired_at_mixin.py
Normal file
89
net/xeaf/rack/models/mixins/expired_at_mixin.py
Normal 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
|
||||
34
net/xeaf/rack/models/mixins/slug_mixin.py
Normal file
34
net/xeaf/rack/models/mixins/slug_mixin.py
Normal 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
|
||||
70
net/xeaf/rack/models/mixins/updated_at_mixin.py
Normal file
70
net/xeaf/rack/models/mixins/updated_at_mixin.py
Normal 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
|
||||
34
net/xeaf/rack/models/mixins/uuid_id_mixin.py
Normal file
34
net/xeaf/rack/models/mixins/uuid_id_mixin.py
Normal 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
|
||||
16
net/xeaf/rack/models/responses/__init__.py
Normal file
16
net/xeaf/rack/models/responses/__init__.py
Normal 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
|
||||
33
net/xeaf/rack/models/responses/bool_response.py
Normal file
33
net/xeaf/rack/models/responses/bool_response.py
Normal 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)
|
||||
33
net/xeaf/rack/models/responses/data_response.py
Normal file
33
net/xeaf/rack/models/responses/data_response.py
Normal 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)
|
||||
30
net/xeaf/rack/models/responses/empty_response.py
Normal file
30
net/xeaf/rack/models/responses/empty_response.py
Normal 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)
|
||||
51
net/xeaf/rack/models/responses/error_response.py
Normal file
51
net/xeaf/rack/models/responses/error_response.py
Normal 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
|
||||
40
net/xeaf/rack/models/responses/exception_response.py
Normal file
40
net/xeaf/rack/models/responses/exception_response.py
Normal 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)
|
||||
33
net/xeaf/rack/models/responses/list_response.py
Normal file
33
net/xeaf/rack/models/responses/list_response.py
Normal 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)
|
||||
95
net/xeaf/rack/models/session_model.py
Normal file
95
net/xeaf/rack/models/session_model.py
Normal 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")
|
||||
12
net/xeaf/rack/models/user_story/__init__.py
Normal file
12
net/xeaf/rack/models/user_story/__init__.py
Normal 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
|
||||
92
net/xeaf/rack/models/user_story/account_test_data.py
Normal file
92
net/xeaf/rack/models/user_story/account_test_data.py
Normal 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)
|
||||
82
net/xeaf/rack/models/user_story/session_test_data.py
Normal file
82
net/xeaf/rack/models/user_story/session_test_data.py
Normal 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)
|
||||
11
net/xeaf/rack/serializers/__init__.py
Normal file
11
net/xeaf/rack/serializers/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# DRF Rack
|
||||
# Библиотека классов расширений для Django REST Framework
|
||||
#
|
||||
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||
# Все права защищены. Лицензия: MIT
|
||||
|
||||
"""
|
||||
Пакет описания классов сериализаторов
|
||||
"""
|
||||
|
||||
from .version_info_response import VersionInfoResponse
|
||||
26
net/xeaf/rack/serializers/version_info_response.py
Normal file
26
net/xeaf/rack/serializers/version_info_response.py
Normal 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()
|
||||
""" Признак режима отладки """
|
||||
19
net/xeaf/rack/tests/__init__.py
Normal file
19
net/xeaf/rack/tests/__init__.py
Normal 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
|
||||
)
|
||||
23
net/xeaf/rack/tests/core/__init__.py
Normal file
23
net/xeaf/rack/tests/core/__init__.py
Normal 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
|
||||
25
net/xeaf/rack/tests/core/_core_command_mocks.py
Normal file
25
net/xeaf/rack/tests/core/_core_command_mocks.py
Normal 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
|
||||
|
||||
66
net/xeaf/rack/tests/core/_core_enum_mocks.py
Normal file
66
net/xeaf/rack/tests/core/_core_enum_mocks.py
Normal 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
|
||||
@@ -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)
|
||||
177
net/xeaf/rack/tests/core/core_command_is_debug_or_test_tests.py
Normal file
177
net/xeaf/rack/tests/core/core_command_is_debug_or_test_tests.py
Normal 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
|
||||
317
net/xeaf/rack/tests/core/core_command_report_int_value_tests.py
Normal file
317
net/xeaf/rack/tests/core/core_command_report_int_value_tests.py
Normal 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)
|
||||
229
net/xeaf/rack/tests/core/core_command_report_separator_tests.py
Normal file
229
net/xeaf/rack/tests/core/core_command_report_separator_tests.py
Normal 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)
|
||||
52
net/xeaf/rack/tests/core/core_enum_check_is_empty_tests.py
Normal file
52
net/xeaf/rack/tests/core/core_enum_check_is_empty_tests.py
Normal 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))
|
||||
77
net/xeaf/rack/tests/core/core_enum_choices_tests.py
Normal file
77
net/xeaf/rack/tests/core/core_enum_choices_tests.py
Normal 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)
|
||||
123
net/xeaf/rack/tests/core/core_enum_from_string_tests.py
Normal file
123
net/xeaf/rack/tests/core/core_enum_from_string_tests.py
Normal 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))
|
||||
69
net/xeaf/rack/tests/core/core_enum_keys_tests.py
Normal file
69
net/xeaf/rack/tests/core/core_enum_keys_tests.py
Normal 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"])
|
||||
85
net/xeaf/rack/tests/core/core_enum_values_tests.py
Normal file
85
net/xeaf/rack/tests/core/core_enum_values_tests.py
Normal 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"])
|
||||
@@ -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())
|
||||
130
net/xeaf/rack/tests/core/core_exception_process_meta_tests.py
Normal file
130
net/xeaf/rack/tests/core/core_exception_process_meta_tests.py
Normal 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")
|
||||
213
net/xeaf/rack/tests/core/core_exception_tests.py
Normal file
213
net/xeaf/rack/tests/core/core_exception_tests.py
Normal 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))
|
||||
307
net/xeaf/rack/tests/core/core_response_tests.py
Normal file
307
net/xeaf/rack/tests/core/core_response_tests.py
Normal 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"))
|
||||
11
net/xeaf/rack/tests/utils/__init__.py
Normal file
11
net/xeaf/rack/tests/utils/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# DRF Rack
|
||||
# Библиотека классов расширений для Django REST Framework
|
||||
#
|
||||
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
|
||||
# Все права защищены. Лицензия: MIT
|
||||
|
||||
"""
|
||||
Пакет описания тестов для служебных классов
|
||||
"""
|
||||
|
||||
from .settings import *
|
||||
20
net/xeaf/rack/tests/utils/settings/__init__.py
Normal file
20
net/xeaf/rack/tests/utils/settings/__init__.py
Normal 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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
55
net/xeaf/rack/tests/utils/settings/environment_env_tests.py
Normal file
55
net/xeaf/rack/tests/utils/settings/environment_env_tests.py
Normal 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))
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user