From b713a2124b378ddbccf4a82675f76799a82de029 Mon Sep 17 00:00:00 2001 From: "Nick V. Anokhin" Date: Thu, 16 Jul 2026 11:52:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=20CoreViewSet=20=D0=B8?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BA=20=D0=BD=D0=B5=D0=BC?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- net/xeaf/rack/core/__init__.py | 1 - net/xeaf/rack/core/core_test_case.py | 25 -- net/xeaf/rack/core/tests/__init__.py | 11 + net/xeaf/rack/core/tests/core_test_case.py | 175 ++++++++++ net/xeaf/rack/core/views/__init__.py | 16 + net/xeaf/rack/core/views/core_view_set.py | 283 ++++++++++++++++ .../rack/core/views/view_set_create_mixin.py | 92 +++++ .../rack/core/views/view_set_destroy_mixin.py | 95 ++++++ .../rack/core/views/view_set_list_mixin.py | 85 +++++ .../core/views/view_set_retrieve_mixin.py | 88 +++++ .../rack/core/views/view_set_update_mixin.py | 107 ++++++ net/xeaf/rack/enums/__init__.py | 4 +- net/xeaf/rack/enums/http_method.py | 42 +++ net/xeaf/rack/enums/view_set_action.py | 32 +- .../management/userstory/account_test_data.py | 6 + .../management/userstory/session_test_data.py | 20 ++ net/xeaf/rack/tests/core/__init__.py | 2 + net/xeaf/rack/tests/core/views/__init__.py | 17 + .../core_view_set_get_serializer_tests.py | 70 ++++ .../views/core_view_set_is_partial_update.py | 107 ++++++ .../core_view_set_path_generation_tests.py | 163 +++++++++ .../core_view_set_process_data_params.py | 320 ++++++++++++++++++ .../core_view_set_process_pk_param_tests.py | 72 ++++ ...ore_view_set_process_query_params_tests.py | 278 +++++++++++++++ .../core_view_set_request_context_tests.py | 136 ++++++++ .../tests/managers/sesson_manager_tests.py | 2 +- .../rack/tests/models/account_model_tests.py | 2 +- .../rack/tests/models/session_model_tests.py | 2 +- .../expiring_token_authentication_tests.py | 2 +- net/xeaf/rack/utils/__init__.py | 1 + net/xeaf/rack/utils/model_utils.py | 53 +++ wsgiapp/settings.py | 9 + 32 files changed, 2263 insertions(+), 55 deletions(-) delete mode 100644 net/xeaf/rack/core/core_test_case.py create mode 100644 net/xeaf/rack/core/tests/__init__.py create mode 100644 net/xeaf/rack/core/tests/core_test_case.py create mode 100644 net/xeaf/rack/core/views/__init__.py create mode 100644 net/xeaf/rack/core/views/core_view_set.py create mode 100644 net/xeaf/rack/core/views/view_set_create_mixin.py create mode 100644 net/xeaf/rack/core/views/view_set_destroy_mixin.py create mode 100644 net/xeaf/rack/core/views/view_set_list_mixin.py create mode 100644 net/xeaf/rack/core/views/view_set_retrieve_mixin.py create mode 100644 net/xeaf/rack/core/views/view_set_update_mixin.py create mode 100644 net/xeaf/rack/enums/http_method.py create mode 100644 net/xeaf/rack/tests/core/views/__init__.py create mode 100644 net/xeaf/rack/tests/core/views/core_view_set_get_serializer_tests.py create mode 100644 net/xeaf/rack/tests/core/views/core_view_set_is_partial_update.py create mode 100644 net/xeaf/rack/tests/core/views/core_view_set_path_generation_tests.py create mode 100644 net/xeaf/rack/tests/core/views/core_view_set_process_data_params.py create mode 100644 net/xeaf/rack/tests/core/views/core_view_set_process_pk_param_tests.py create mode 100644 net/xeaf/rack/tests/core/views/core_view_set_process_query_params_tests.py create mode 100644 net/xeaf/rack/tests/core/views/core_view_set_request_context_tests.py create mode 100644 net/xeaf/rack/utils/model_utils.py diff --git a/net/xeaf/rack/core/__init__.py b/net/xeaf/rack/core/__init__.py index 90b0821..ae6270c 100644 --- a/net/xeaf/rack/core/__init__.py +++ b/net/xeaf/rack/core/__init__.py @@ -16,4 +16,3 @@ from .core_model import CoreModel from .core_persistent_model import CorePersistentModel from .core_persistent_model import CorePersistentModel from .core_response import CoreResponse -from .core_test_case import CoreTestCase diff --git a/net/xeaf/rack/core/core_test_case.py b/net/xeaf/rack/core/core_test_case.py deleted file mode 100644 index 65fa345..0000000 --- a/net/xeaf/rack/core/core_test_case.py +++ /dev/null @@ -1,25 +0,0 @@ -# DRF Rack -# Библиотека классов расширений для Django REST Framework -# -# Автор: Николай В. Анохин -# Все права защищены. Лицензия: MIT - -""" -Описание класса CoreTestCase -""" - -from django.core.management import call_command -from django.test import TestCase - -class CoreTestCase(TestCase): - """ - Реализует инициализацию тестовой базы данных перед каждым тестом - """ - - def setUp(self): - """ - Подготовка к тестированию - """ - - call_command("rack_test_database") - diff --git a/net/xeaf/rack/core/tests/__init__.py b/net/xeaf/rack/core/tests/__init__.py new file mode 100644 index 0000000..aeb34e8 --- /dev/null +++ b/net/xeaf/rack/core/tests/__init__.py @@ -0,0 +1,11 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет базовых классов для реализации тестов +""" + +from .core_test_case import CoreTestCase diff --git a/net/xeaf/rack/core/tests/core_test_case.py b/net/xeaf/rack/core/tests/core_test_case.py new file mode 100644 index 0000000..65d9c85 --- /dev/null +++ b/net/xeaf/rack/core/tests/core_test_case.py @@ -0,0 +1,175 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreTestCase +""" + +import json + +from django.core.management import call_command +from django.test import TestCase +from rest_framework.parsers import JSONParser +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from net.xeaf.rack.management.userstory import AccountTestData +from net.xeaf.rack.management.userstory import SessionTestData +from net.xeaf.rack.models import AccountModel +from net.xeaf.rack.models import SessionModel + + +class CoreTestCase(TestCase): + """ + Реализует инициализацию тестовой базы данных перед каждым тестом + """ + + factory: APIRequestFactory + """ Фабрика запросов """ + + def setUp(self): + """ + Подготовка к тестированию + """ + + call_command("rack_test_database") + self.factory = APIRequestFactory() + + def create_request(self, method, path, data=None, query_params=None, auth=None): + """ + Создает DRF Request объект для тестирования + + :param method: HTTP метод (GET, POST, PUT, PATCH, DELETE) + :param path: URL путь + :param data: Данные для тела запроса + :param query_params: Параметры запроса + :param auth: Объект аутентификации (сессия) + + :return: DRF Request объект + """ + + method = method.upper() + data = data or {} + query_params = query_params or {} + + # Создаем WSGI request в зависимости от метода + if method == 'GET': + wsgi_request = self.factory.get(path, data=query_params) + elif method == 'POST': + wsgi_request = self.factory.post(path, data=data, format='json') + elif method == 'PUT': + wsgi_request = self.factory.put(path, data=data, format='json') + elif method == 'PATCH': + wsgi_request = self.factory.patch(path, data=data, format='json') + elif method == 'DELETE': + wsgi_request = self.factory.delete(path, data=data, format='json') + else: + + wsgi_request = self.factory.generic( + method, + path, + content_type='application/json', + data=json.dumps(data) if data else '' + ) + + # Преобразуем в DRF Request + drf_request = Request(wsgi_request, parsers=[JSONParser()]) + + # Добавляем аутентификацию если передана + if auth: + drf_request.auth = auth + if hasattr(auth, 'account'): + drf_request.user = auth.account + + # Добавляем query_params для не-GET запросов + if query_params and method != 'GET': + # noinspection PyTypeChecker + drf_request.query_params._mutable = True + for key, value in query_params.items(): + drf_request.query_params[key] = value + drf_request.query_params._mutable = False + + return drf_request + + def create_authenticated_request(self, method, path, token, data=None, query_params=None): + """ + Создает аутентифицированный DRF Request + + :param method: HTTP метод + :param path: URL путь + :param token: Токен сессии + :param data: Данные для тела запроса + :param query_params: Параметры запроса + :return: DRF Request с аутентификацией + """ + + session = SessionModel.objects.get(token=token) + return self.create_request(method, path, data, query_params, auth=session) + + @classmethod + def get_i_ivanov_account(cls) -> AccountModel: + """ + Возвращает аккаунт Ивана И. Иванова + + :return: Аккаунт Ивана И. Иванова + """ + + return AccountTestData.get_i_ivanov_account() + + @classmethod + def get_i_ivanov_session(cls) -> SessionModel: + """ + Возвращает сессию Ивана И. Иванова + + :return: Сессия Ивана И. Иванова + """ + + return SessionTestData.get_i_ivanov_session() + + def create_i_ivanov_request(self, method, path, data=None, query_params=None): + """ + Создает запрос от имени Ивана И. Иванова + """ + + return self.create_authenticated_request( + method, + path, + SessionTestData.I_IVANOV_SESSION_TOKEN, + data, + query_params + ) + + @classmethod + def get_p_petrov_account(cls) -> AccountModel: + """ + Возвращает аккаунт Петра П. Петрова + + :return: Аккаунт Петра П. Петрова + """ + return AccountTestData.get_p_petrov_account() + + @classmethod + def get_p_petrov_session(cls) -> SessionModel: + """ + Возвращает сессию Петра П. Петрова + + :return: Сессия Петра П. Петрова + """ + + return SessionTestData.get_p_petrov_session() + + def create_p_petrov_request(self, method, path, data=None, query_params=None): + """ + Создает запрос от имени Петра П. Петрова + """ + + return self.create_authenticated_request( + method, + path, + SessionTestData.P_PETROV_SESSION_TOKEN, + data, + query_params + ) diff --git a/net/xeaf/rack/core/views/__init__.py b/net/xeaf/rack/core/views/__init__.py new file mode 100644 index 0000000..5727687 --- /dev/null +++ b/net/xeaf/rack/core/views/__init__.py @@ -0,0 +1,16 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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 diff --git a/net/xeaf/rack/core/views/core_view_set.py b/net/xeaf/rack/core/views/core_view_set.py new file mode 100644 index 0000000..0e69c0b --- /dev/null +++ b/net/xeaf/rack/core/views/core_view_set.py @@ -0,0 +1,283 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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 AttributeError(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_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))] diff --git a/net/xeaf/rack/core/views/view_set_create_mixin.py b/net/xeaf/rack/core/views/view_set_create_mixin.py new file mode 100644 index 0000000..727d977 --- /dev/null +++ b/net/xeaf/rack/core/views/view_set_create_mixin.py @@ -0,0 +1,92 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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) diff --git a/net/xeaf/rack/core/views/view_set_destroy_mixin.py b/net/xeaf/rack/core/views/view_set_destroy_mixin.py new file mode 100644 index 0000000..8e1097f --- /dev/null +++ b/net/xeaf/rack/core/views/view_set_destroy_mixin.py @@ -0,0 +1,95 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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) diff --git a/net/xeaf/rack/core/views/view_set_list_mixin.py b/net/xeaf/rack/core/views/view_set_list_mixin.py new file mode 100644 index 0000000..fa8bfb8 --- /dev/null +++ b/net/xeaf/rack/core/views/view_set_list_mixin.py @@ -0,0 +1,85 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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)) diff --git a/net/xeaf/rack/core/views/view_set_retrieve_mixin.py b/net/xeaf/rack/core/views/view_set_retrieve_mixin.py new file mode 100644 index 0000000..2426ae4 --- /dev/null +++ b/net/xeaf/rack/core/views/view_set_retrieve_mixin.py @@ -0,0 +1,88 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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) diff --git a/net/xeaf/rack/core/views/view_set_update_mixin.py b/net/xeaf/rack/core/views/view_set_update_mixin.py new file mode 100644 index 0000000..800b4f7 --- /dev/null +++ b/net/xeaf/rack/core/views/view_set_update_mixin.py @@ -0,0 +1,107 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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) diff --git a/net/xeaf/rack/enums/__init__.py b/net/xeaf/rack/enums/__init__.py index fc5cf30..6dde42e 100644 --- a/net/xeaf/rack/enums/__init__.py +++ b/net/xeaf/rack/enums/__init__.py @@ -8,7 +8,7 @@ Пакет описания классов перечислений """ -from .ui_language import UILanguage from .auth_reject_reson import AuthRejectReason +from .http_method import HttpMethod +from .ui_language import UILanguage from .view_set_action import ViewSetAction - diff --git a/net/xeaf/rack/enums/http_method.py b/net/xeaf/rack/enums/http_method.py new file mode 100644 index 0000000..e55e8d4 --- /dev/null +++ b/net/xeaf/rack/enums/http_method.py @@ -0,0 +1,42 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: 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' + """ Получение возможных действий """ + \ No newline at end of file diff --git a/net/xeaf/rack/enums/view_set_action.py b/net/xeaf/rack/enums/view_set_action.py index d618a32..070ca43 100644 --- a/net/xeaf/rack/enums/view_set_action.py +++ b/net/xeaf/rack/enums/view_set_action.py @@ -19,45 +19,31 @@ class ViewSetAction(CoreEnum): Перечисление действий набора представлений """ - LIST = "list" + LIST = "LIST" """ Список объектов """ - RETRIEVE = "retrieve" + RETRIEVE = "RETRIEVE" """ Получение объекта """ - CREATE = "create" + CREATE = "CREATE" """ Создание объекта """ - UPDATE = "update" + UPDATE = "UPDATE" """ Изменение объекта """ - PARTIAL_UPDATE = "partial_update" + PARTIAL_UPDATE = "PARTIAL_UPDATE" """ Частичное изменение объекта """ - DESTROY = "destroy" + DESTROY = "DESTROY" """ Удаление объекта """ @classmethod def get_method(cls, action: ViewSetAction) -> str: """ - Возвращает HTTP метод для указанного действия + Возвращает идентификатор метода класса для указанного действия :param action: Действие - :return: HTTP метод + :return: Идентификатор метода класса """ - match action: - case cls.LIST: - return "item_list" - case cls.CREATE: - return "item_create" - case cls.RETRIEVE: - return "item_retrieve" - case cls.UPDATE: - return "item_update" - case cls.PARTIAL_UPDATE: - return "item_partial_update" - case cls.DESTROY: - return "item_destroy" - case _: - raise ValueError(f"Invalid action: {action}") + return f"item_{action.value.lower()}" diff --git a/net/xeaf/rack/management/userstory/account_test_data.py b/net/xeaf/rack/management/userstory/account_test_data.py index ee71be5..dd3b2b0 100644 --- a/net/xeaf/rack/management/userstory/account_test_data.py +++ b/net/xeaf/rack/management/userstory/account_test_data.py @@ -22,9 +22,15 @@ class AccountTestData: I_IVANOV_ID = uuid.UUID("bd428358-6944-4657-8044-a3dc1c4dff8a") """ Идентификатор пользователя Иван И. Иванов """ + I_IVANOV_USERNAME = 'i.ivanov@xeaf.net' + """ Имя пользователя Иван И. Иванов """ + P_PETROV_ID = uuid.UUID("9c5010cc-f3be-42ae-bc05-d142d68a610a") """ Идентификатор пользователя Петр П. Петров """ + P_PETROV_USERNAME = 'p.petrov@xeaf.net' + """ Имя пользователя Петр П. Петров """ + DEFAULT_PASSWORD = "manager123!" """ Пароль по умолчанию для тестов """ diff --git a/net/xeaf/rack/management/userstory/session_test_data.py b/net/xeaf/rack/management/userstory/session_test_data.py index f2788e3..b661c7a 100644 --- a/net/xeaf/rack/management/userstory/session_test_data.py +++ b/net/xeaf/rack/management/userstory/session_test_data.py @@ -60,3 +60,23 @@ class SessionTestData: session.actual_account_id = AccountTestData.P_PETROV_ID session.renew(cls.TEST_SESSION_TTL) session.save() + + @classmethod + def get_i_ivanov_session(cls) -> SessionModel: + """ + Возвращает сессию пользователя Иван И. Иванов + + :return: Сессия пользователя Иван И. Иванов + """ + + return SessionModel.objects.get(token=cls.I_IVANOV_SESSION_TOKEN) + + @classmethod + def get_p_petrov_session(cls) -> SessionModel: + """ + Возвращает сессию пользователя Петр П. Петров + + :return: Сессия пользователя Петр П. Петров + """ + + return SessionModel.objects.get(token=cls.P_PETROV_SESSION_TOKEN) diff --git a/net/xeaf/rack/tests/core/__init__.py b/net/xeaf/rack/tests/core/__init__.py index 2157389..e07c41a 100644 --- a/net/xeaf/rack/tests/core/__init__.py +++ b/net/xeaf/rack/tests/core/__init__.py @@ -11,3 +11,5 @@ from .core_enum_tests import CoreEnumTests from .core_exception_tests import CoreExceptionTests from .core_response_tests import CoreResponseTests + +from .views import * diff --git a/net/xeaf/rack/tests/core/views/__init__.py b/net/xeaf/rack/tests/core/views/__init__.py new file mode 100644 index 0000000..54c4c7f --- /dev/null +++ b/net/xeaf/rack/tests/core/views/__init__.py @@ -0,0 +1,17 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет описания тестов для базовых классов представлений +""" + +from .core_view_set_get_serializer_tests import CoreViewSetGetSerializerTests +from .core_view_set_is_partial_update import CoreViewSetIsPartialUpdateTests +from .core_view_set_process_data_params import CoreViewSetProcessDataParamsTests +from .core_view_set_process_pk_param_tests import CoreViewSetProcessPkParamTests +from .core_view_set_process_query_params_tests import CoreViewSetProcessQueryParamsTests +# from .core_view_set_path_generation_tests import CoreViewSetPathGenerationTests +from .core_view_set_request_context_tests import CoreViewSetRequestContextTests diff --git a/net/xeaf/rack/tests/core/views/core_view_set_get_serializer_tests.py b/net/xeaf/rack/tests/core/views/core_view_set_get_serializer_tests.py new file mode 100644 index 0000000..9e946fd --- /dev/null +++ b/net/xeaf/rack/tests/core/views/core_view_set_get_serializer_tests.py @@ -0,0 +1,70 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreViewSetGetSerializerTest +""" + +from django.test import SimpleTestCase +from rest_framework import serializers + +from net.xeaf.rack.core.views import CoreViewSet + + +class MockSerializer(serializers.Serializer): + """ + Тестовый класс сериализатора + """ + + field = serializers.CharField() + """ Поле сериализатора """ + + +class MockViewSet(CoreViewSet): + """ + Тестовый класс представления + """ + + pass + + +class CoreViewSetGetSerializerTests(SimpleTestCase): + """ + Тесты метода CoreViewSet._get_serializer + """ + + def test_get_serializer_with_serializer_class(self): + """ + Передан явный класс сериализатора + """ + + view_set = MockViewSet() + custom_serializer = MockSerializer + result = view_set._get_serializer(custom_serializer, None) + + self.assertEqual(result, custom_serializer) + + def test_get_serializer_with_default(self): + """ + Передан класс по умолчанию + """ + + view_set = MockViewSet() + default_serializer = MockSerializer + result = view_set._get_serializer(None, default_serializer) + + self.assertEqual(result, default_serializer) + + def test_get_serializer_no_serializer_raises_error(self): + """ + Не передан сериализатор - ошибка + """ + + view_set = MockViewSet() + with self.assertRaises(AttributeError) as context: + view_set._get_serializer(None, None) + + self.assertIn("must define 'serializer' attribute", str(context.exception)) diff --git a/net/xeaf/rack/tests/core/views/core_view_set_is_partial_update.py b/net/xeaf/rack/tests/core/views/core_view_set_is_partial_update.py new file mode 100644 index 0000000..2310c89 --- /dev/null +++ b/net/xeaf/rack/tests/core/views/core_view_set_is_partial_update.py @@ -0,0 +1,107 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreViewSetIsPartialUpdateTest +""" + +from net.xeaf.rack.core.tests import CoreTestCase +from net.xeaf.rack.core.views import CoreViewSet + + +class MockViewSetForIsPartialUpdate(CoreViewSet): + """ + ViewSet для тестов _is_partial_update + """ + + permission_classes = () + + +class CoreViewSetIsPartialUpdateTests(CoreTestCase): + """ + Тесты для CoreViewSet._is_partial_update + """ + + def setUp(self): + """ + Подготовка к тестированию + """ + + super().setUp() + self.view_set = MockViewSetForIsPartialUpdate() + + def test_is_partial_update_for_patch(self): + """ + PATCH запрос - возвращает True + """ + + request = self.create_i_ivanov_request( + 'PATCH', + '/test', + data={'name': 'Updated'} + ) + self.view_set.request = request + result = self.view_set._is_partial_update() + + self.assertTrue(result) + + def test_is_partial_update_for_put(self): + """ + PUT запрос - возвращает False + """ + + request = self.create_i_ivanov_request( + 'PUT', + '/test', + data={'name': 'Updated', 'age': 30} + ) + self.view_set.request = request + result = self.view_set._is_partial_update() + + self.assertFalse(result) + + def test_is_partial_update_for_post(self): + """ + POST запрос - возвращает False + """ + + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={'name': 'New', 'age': 25} + ) + self.view_set.request = request + result = self.view_set._is_partial_update() + + self.assertFalse(result) + + def test_is_partial_update_for_delete(self): + """ + DELETE запрос - возвращает False + """ + + request = self.create_i_ivanov_request( + 'DELETE', + '/test/123/' + ) + self.view_set.request = request + result = self.view_set._is_partial_update() + + self.assertFalse(result) + + def test_is_partial_update_for_get(self): + """ + GET запрос - возвращает False + """ + + request = self.create_i_ivanov_request( + 'GET', + '/test' + ) + self.view_set.request = request + result = self.view_set._is_partial_update() + + self.assertFalse(result) diff --git a/net/xeaf/rack/tests/core/views/core_view_set_path_generation_tests.py b/net/xeaf/rack/tests/core/views/core_view_set_path_generation_tests.py new file mode 100644 index 0000000..ea30020 --- /dev/null +++ b/net/xeaf/rack/tests/core/views/core_view_set_path_generation_tests.py @@ -0,0 +1,163 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreViewSetPathGenerationTests +""" + +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase +from net.xeaf.rack.core.views import CoreViewSet +from net.xeaf.rack.enums import ViewSetAction + + +class CoreViewSetPathGenerationTest(SimpleTestCase): + """ + Тесты генерации URL паттернов + """ + + class FullCRUDViewSet(CoreViewSet): + """ + ViewSet с полным CRUD + """ + + item_list_pattern = "test/" + item_detail_pattern = "test//" + + @classmethod + def item_list(cls, request): + pass + + @classmethod + def item_create(cls, request): + pass + + @classmethod + def item_retrieve(cls, request, pk): + pass + + @classmethod + def item_update(cls, request, pk): + pass + + @classmethod + def item_partial_update(cls, request, pk): + pass + + @classmethod + def item_destroy(cls, request, pk): + pass + + class ListOnlyViewSet(CoreViewSet): + """ + ViewSet только для списка + """ + + item_list_pattern = "test/" + + @classmethod + def item_list(cls, request): + pass + + class DetailOnlyViewSet(CoreViewSet): + """ + ViewSet только для деталей + """ + + item_detail_pattern = "test//" + + @classmethod + def item_retrieve(cls, request, pk): + pass + + def test_get_paths_full_crud(self): + """ + Полный CRUD - все пути сгенерированы + """ + + paths = self.FullCRUDViewSet.get_paths() + + self.assertEqual(len(paths), 2) + + list_path = paths[0] + detail_path = paths[1] + + self.assertEqual(list_path.pattern._route, "test/") + self.assertEqual(detail_path.pattern._route, "test//") + + def test_get_paths_list_only(self): + """ + Только список - только list path + """ + + paths = self.ListOnlyViewSet.get_paths() + + self.assertEqual(len(paths), 1) + + list_path = paths[0] + self.assertEqual(list_path.pattern._route, "test/") + + def test_get_paths_detail_only(self): + """ + Только детали - только detail path + """ + + paths = self.DetailOnlyViewSet.get_paths() + + self.assertEqual(len(paths), 1) + + detail_path = paths[0] + self.assertEqual(detail_path.pattern._route, "test//") + + def test_get_paths_no_patterns(self): + """ + Нет паттернов - пустой список + """ + + class EmptyViewSet(CoreViewSet): + pass + + paths = EmptyViewSet.get_paths() + + self.assertEqual(paths, []) + + def test_get_paths_list_without_methods(self): + """ + Есть паттерн, но нет методов - пустой список + """ + + class NoMethodsViewSet(CoreViewSet): + item_list_pattern = "test/" + + with self.assertRaises(ImproperlyConfigured): + NoMethodsViewSet.get_paths() + + def test_path_method_valid_action(self): + """ + Получение метода для валидного действия + """ + + result = CoreViewSet._path_method(ViewSetAction.LIST) + self.assertEqual(result, "item_list") + + def test_path_method_exists_true(self): + """ + Проверка существования метода - True + """ + + result = self.FullCRUDViewSet._path_method_exists(ViewSetAction.LIST) + self.assertTrue(result) + + def test_path_method_exists_false(self): + """ + Проверка существования метода - False + """ + + class EmptyViewSet(CoreViewSet): + pass + + result = EmptyViewSet._path_method_exists(ViewSetAction.LIST) + self.assertFalse(result) \ No newline at end of file diff --git a/net/xeaf/rack/tests/core/views/core_view_set_process_data_params.py b/net/xeaf/rack/tests/core/views/core_view_set_process_data_params.py new file mode 100644 index 0000000..58f9e21 --- /dev/null +++ b/net/xeaf/rack/tests/core/views/core_view_set_process_data_params.py @@ -0,0 +1,320 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreViewSetProcessDataParamsTestsTest +""" + +from rest_framework import serializers + +from net.xeaf.rack.core.tests import CoreTestCase +from net.xeaf.rack.core.views import CoreViewSet + + +class DataParamsSerializer(serializers.Serializer): + """ + Сериализатор для тестов data params + """ + + name = serializers.CharField(max_length=100) + age = serializers.IntegerField(min_value=0, max_value=150) + email = serializers.EmailField(required=False) + + +class MockViewSetForDataParams(CoreViewSet): + """ + ViewSet для тестов data params + """ + + permission_classes = () + + def _get_request_method(self): + """ + Метод по умолчанию + """ + + return 'POST' # По умолчанию POST + + def _get_request_session(self): + """ + Нет сессии + """ + + return None + + +class CoreViewSetProcessDataParamsTests(CoreTestCase): + """ + Тесты для CoreViewSet._process_data_params + """ + + def setUp(self): + """ + Подготовка к тестам + """ + + super().setUp() + self.view_set = MockViewSetForDataParams() + + def test_process_data_params_no_serializer(self): + """ + Без сериализатора - возвращаются исходные данные + """ + + data = {'name': 'Test', 'age': 25} + request = self.create_i_ivanov_request( + 'POST', + '/test', + data=data + ) + self.view_set.request = request + result = self.view_set._process_data_params(None) + + self.assertEqual(result, data) + + def test_process_data_params_with_serializer(self): + """ + С сериализатором - валидирует и возвращает данные + Проверяет, что данные проходят валидацию через сериализатор + """ + + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={'name': 'Ivan Ivanov', 'age': 30} + ) + self.view_set.request = request + result = self.view_set._process_data_params(DataParamsSerializer) + + self.assertEqual(result['name'], 'Ivan Ivanov') + self.assertEqual(result['age'], 30) + self.assertIsInstance(result['age'], int) + + def test_process_data_params_with_serializer_optional_fields(self): + """ + С сериализатором - опциональные поля + Проверяет, что опциональные поля могут отсутствовать + """ + + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={'name': 'Ivan Ivanov', 'age': 30} + ) + self.view_set.request = request + result = self.view_set._process_data_params(DataParamsSerializer) + + self.assertEqual(result['name'], 'Ivan Ivanov') + self.assertEqual(result['age'], 30) + self.assertNotIn('email', result) + + def test_process_data_params_with_serializer_optional_field_present(self): + """ + Опциональное поле передано + Проверяет, что опциональное поле обрабатывается корректно + """ + + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={ + 'name': 'Ivan Ivanov', + 'age': 30, + 'email': 'ivan@example.com' + } + ) + self.view_set.request = request + result = self.view_set._process_data_params(DataParamsSerializer) + + self.assertEqual(result['name'], 'Ivan Ivanov') + self.assertEqual(result['age'], 30) + self.assertEqual(result['email'], 'ivan@example.com') + + def test_process_data_params_with_serializer_invalid_raises_error(self): + """ + Невалидные данные - ошибка валидации + Проверяет, что сериализатор выбрасывает ValidationError + """ + + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={'name': '', 'age': 200} # name пустой, age > 150 + ) + self.view_set.request = request + + with self.assertRaises(serializers.ValidationError): + self.view_set._process_data_params(DataParamsSerializer) + + def test_process_data_params_with_serializer_missing_required(self): + """ + Отсутствие обязательного поля + Проверяет, что при отсутствии required поля выбрасывается ошибка + """ + # Arrange + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={'age': 30} # name отсутствует + ) + self.view_set.request = request + + with self.assertRaises(serializers.ValidationError): + self.view_set._process_data_params(DataParamsSerializer) + + def test_process_data_params_with_serializer_invalid_email(self): + """ + Невалидный email + Проверяет, что невалидный email вызывает ошибку + """ + + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={ + 'name': 'Ivan Ivanov', + 'age': 30, + 'email': 'invalid-email' # невалидный email + } + ) + self.view_set.request = request + + with self.assertRaises(serializers.ValidationError): + self.view_set._process_data_params(DataParamsSerializer) + + def test_process_data_params_with_partial_update(self): + """ + Частичное обновление (PATCH) - partial=True + Проверяет, что при PATCH запросе partial=True + """ + + class PartialViewSet(MockViewSetForDataParams): + def _get_request_method(self): + return 'PATCH' + + view_set = PartialViewSet() + request = self.create_i_ivanov_request( + 'PATCH', + '/test', + data={'name': 'Updated Name'} + ) + view_set.request = request + result = view_set._process_data_params(DataParamsSerializer) + + self.assertEqual(result['name'], 'Updated Name') + self.assertNotIn('age', result) + self.assertNotIn('email', result) + + def test_process_data_params_required_field_missing_partial(self): + """ + Отсутствие обязательного поля при partial=True + Проверяет, что при partial=True можно не указывать обязательные поля + """ + + class PartialViewSet(MockViewSetForDataParams): + def _get_request_method(self): + return 'PATCH' + + view_set = PartialViewSet() + request = self.create_i_ivanov_request( + 'PATCH', + '/test', + data={'email': 'test@example.com'} # только опциональное поле + ) + view_set.request = request + result = view_set._process_data_params(DataParamsSerializer) + + self.assertEqual(result['email'], 'test@example.com') + self.assertNotIn('name', result) + self.assertNotIn('age', result) + + def test_process_data_params_required_field_missing_full_update(self): + """ + Отсутствие обязательного поля при полном обновлении (PUT) + Проверяет, что при PUT обязательные поля должны быть указаны + """ + + class PutViewSet(MockViewSetForDataParams): + def _get_request_method(self): + return 'PUT' + + view_set = PutViewSet() + request = self.create_i_ivanov_request( + 'PUT', + '/test', + data={'email': 'test@example.com'} # name и age отсутствуют + ) + view_set.request = request + + with self.assertRaises(serializers.ValidationError): + view_set._process_data_params(DataParamsSerializer) + + def test_process_data_params_with_extra_fields(self): + """ + Лишние поля в данных - сериализатор их игнорирует + Проверяет, что сериализатор игнорирует поля, не определенные в нем + """ + # Arrange + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={ + 'name': 'Ivan Ivanov', + 'age': 30, + 'extra_field': 'should_be_ignored' + } + ) + self.view_set.request = request + result = self.view_set._process_data_params(DataParamsSerializer) + + self.assertEqual(result['name'], 'Ivan Ivanov') + self.assertEqual(result['age'], 30) + self.assertNotIn('extra_field', result) + + def test_process_data_params_with_petrov_user(self): + """ + Запрос от Петрова с данными + Проверяет, что data params работают для любого пользователя + """ + + request = self.create_p_petrov_request( + 'POST', + '/test', + data={'name': 'Petr Petrov', 'age': 25} + ) + self.view_set.request = request + result = self.view_set._process_data_params(DataParamsSerializer) + + self.assertEqual(result['name'], 'Petr Petrov') + self.assertEqual(result['age'], 25) + + def test_process_data_params_with_empty_data(self): + """ + Пустые данные + Проверяет обработку пустого request.data + """ + + request = self.create_i_ivanov_request('POST', '/test', data={}) + self.view_set.request = request + + with self.assertRaises(serializers.ValidationError): + self.view_set._process_data_params(DataParamsSerializer) + + def test_process_data_params_with_null_values(self): + """ + Null значения в данных + Проверяет, что null значения обрабатываются корректно + """ + + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={'name': None, 'age': 30} + ) + self.view_set.request = request + + with self.assertRaises(serializers.ValidationError): + self.view_set._process_data_params(DataParamsSerializer) diff --git a/net/xeaf/rack/tests/core/views/core_view_set_process_pk_param_tests.py b/net/xeaf/rack/tests/core/views/core_view_set_process_pk_param_tests.py new file mode 100644 index 0000000..be6c970 --- /dev/null +++ b/net/xeaf/rack/tests/core/views/core_view_set_process_pk_param_tests.py @@ -0,0 +1,72 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreViewSetProcessPkParamTest +""" + +from uuid import UUID + +from django.test import SimpleTestCase + +from net.xeaf.rack.core.views import CoreViewSet +from net.xeaf.rack.utils.exceptions import BadRequestException + + +class MockViewSet(CoreViewSet): + """ + Тестовый класс представления + """ + + pass + + +class CoreViewSetProcessPkParamTests(SimpleTestCase): + """ + Тесты для CoreViewSet._process_pk_param + """ + + def test_process_pk_param_valid_uuid(self): + """ + Валидный UUID передан + """ + + view_set = MockViewSet() + valid_uuid = "bd428358-6944-4657-8044-a3dc1c4dff8a" + result = view_set._process_pk_param(pk=valid_uuid) + + self.assertIsInstance(result, UUID) + self.assertEqual(str(result), valid_uuid) + + def test_process_pk_param_missing_raises_error(self): + """ + Параметр pk отсутствует - ошибка + """ + + view_set = MockViewSet() + with self.assertRaises(BadRequestException): + view_set._process_pk_param() + + def test_process_pk_param_invalid_uuid_raises_error(self): + """ + Невалидный UUID - ошибка + """ + + view_set = MockViewSet() + with self.assertRaises(ValueError): + view_set._process_pk_param(pk="invalid-uuid") + + def test_process_pk_param_ignores_extra_kwargs(self): + """ + Лишние параметры игнорируются + """ + + view_set = MockViewSet() + valid_uuid = "bd428358-6944-4657-8044-a3dc1c4dff8a" + result = view_set._process_pk_param(pk=valid_uuid, extra="value") + + self.assertIsInstance(result, UUID) + self.assertEqual(str(result), valid_uuid) diff --git a/net/xeaf/rack/tests/core/views/core_view_set_process_query_params_tests.py b/net/xeaf/rack/tests/core/views/core_view_set_process_query_params_tests.py new file mode 100644 index 0000000..81babe5 --- /dev/null +++ b/net/xeaf/rack/tests/core/views/core_view_set_process_query_params_tests.py @@ -0,0 +1,278 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreViewSetProcessQueryParamsTests +""" + +from rest_framework import serializers + +from net.xeaf.rack.core.tests import CoreTestCase +from net.xeaf.rack.core.views import CoreViewSet +from net.xeaf.rack.management.userstory import AccountTestData +from net.xeaf.rack.management.userstory import SessionTestData + + +class QueryParamsSerializer(serializers.Serializer): + """ + Сериализатор параметров тестового запроса + """ + + limit = serializers.IntegerField(required=False, default=10) + offset = serializers.IntegerField(required=False, default=0) + search = serializers.CharField(required=False, default='', allow_blank=True) + + +class MockViewSetForQueryParams(CoreViewSet): + """ + Тестовый класс представления + """ + + permission_classes = () + """ Классы проверки прав доступа """ + + def _get_request_session(self): + """ + Возвращает сессию пользователя из запроса + + :return: Сессия пользователя + """ + + return None + + +class CoreViewSetProcessQueryParamsTests(CoreTestCase): + """ + Тесты для CoreViewSet._process_query_params + """ + + def setUp(self): + """ + Подготовка к тестированию + """ + + super().setUp() + self.view_set = MockViewSetForQueryParams() + + def test_process_query_params_no_serializer(self): + """ + Без сериализатора - возвращает словарь с параметрами + Проверяет, что параметры извлекаются из query_params и мержатся с kwargs + """ + + request = self.create_i_ivanov_request( + 'GET', + '/test', + query_params={ + 'param1': 'value1', + 'param2': 'value2' + } + ) + self.view_set.request = request + + result = self.view_set._process_query_params( + None, + custom_param='custom_value' + ) + + self.assertEqual(result['param1'], 'value1') + self.assertEqual(result['param2'], 'value2') + self.assertEqual(result['custom_param'], 'custom_value') + self.assertNotIn('request', result) + + def test_process_query_params_with_serializer(self): + """ + С сериализатором - валидирует и возвращает данные + Проверяет, что параметры проходят валидацию через сериализатор + """ + + request = self.create_i_ivanov_request( + 'GET', + '/test', + query_params={ + 'limit': '20', + 'offset': '5', + 'search': 'test' + } + ) + self.view_set.request = request + result = self.view_set._process_query_params(QueryParamsSerializer) + + self.assertEqual(result['limit'], 20) + self.assertEqual(result['offset'], 5) + self.assertEqual(result['search'], 'test') + + def test_process_query_params_with_serializer_defaults(self): + """ + С сериализатором - значения по умолчанию + Проверяет, что отсутствующие параметры заполняются значениями по умолчанию + """ + + request = self.create_i_ivanov_request( + 'GET', + '/test', + query_params={} + ) + self.view_set.request = request + result = self.view_set._process_query_params(QueryParamsSerializer) + + self.assertEqual(result['limit'], 10) # default + self.assertEqual(result['offset'], 0) # default + self.assertEqual(result['search'], '') # allow_blank=True + + def test_process_query_params_with_serializer_invalid_raises_error(self): + """ + Невалидные параметры - ошибка валидации + Проверяет, что сериализатор выбрасывает ValidationError + """ + + request = self.create_i_ivanov_request( + 'GET', + '/test', + query_params={ + 'limit': 'invalid', # невалидное значение + 'offset': '5' + } + ) + self.view_set.request = request + + with self.assertRaises(serializers.ValidationError): + self.view_set._process_query_params(QueryParamsSerializer) + + def test_process_query_params_merges_with_kwargs(self): + """ + Параметры из kwargs переопределяют параметры запроса + Проверяет, что kwargs имеют приоритет над query_params + """ + + request = self.create_i_ivanov_request( + 'GET', + '/test', + query_params={'limit': '20'} + ) + self.view_set.request = request + result = self.view_set._process_query_params( + None, + limit=50 # переопределяем + ) + + self.assertEqual(result['limit'], 50) + + def test_process_query_params_with_serializer_ignores_extra_kwargs(self): + """ + С сериализатором - только валидированные параметры + Проверяет, что сериализатор игнорирует параметры, не определенные в нем + """ + + request = self.create_i_ivanov_request( + 'GET', + '/test', + query_params={ + 'limit': '20', + 'offset': '5' + } + ) + self.view_set.request = request + result = self.view_set._process_query_params( + QueryParamsSerializer, + extra_param='extra_value' # не входит в сериализатор + ) + + self.assertNotIn('extra_param', result) + self.assertEqual(result['limit'], 20) + self.assertEqual(result['offset'], 5) + + def test_process_query_params_empty_params(self): + """ + Пустые параметры запроса + Проверяет обработку пустого query_params + """ + + request = self.create_i_ivanov_request('GET', '/test') + self.view_set.request = request + + result = self.view_set._process_query_params(None) + + self.assertEqual(result, {}) + + def test_process_query_params_filters_request_param(self): + """ + Фильтрация параметра 'request' + Проверяет, что параметр 'request' не попадает в результат + """ + + request = self.create_i_ivanov_request( + 'GET', + '/test', + query_params={ + 'request': 'should_be_filtered', + 'param': 'value' + } + ) + self.view_set.request = request + result = self.view_set._process_query_params(None) + + self.assertNotIn('request', result) + self.assertEqual(result['param'], 'value') + + def test_process_query_params_with_petrov_user(self): + """ + Запрос от Петрова с параметрами + Проверяет, что query_params работают для любого пользователя + """ + + request = self.create_p_petrov_request( + 'GET', + '/test', + query_params={ + 'limit': '15', + 'offset': '3' + } + ) + self.view_set.request = request + result = self.view_set._process_query_params(QueryParamsSerializer) + + self.assertEqual(result['limit'], 15) + self.assertEqual(result['offset'], 3) + + def test_process_query_params_with_non_get_method(self): + """ + Не-GET запрос с query_params + Проверяет, что query_params работают и для не-GET запросов + """ + + request = self.create_i_ivanov_request( + 'POST', + '/test', + data={'name': 'Test'}, + query_params={'filter': 'active'} + ) + self.view_set.request = request + result = self.view_set._process_query_params(None) + + self.assertEqual(result['filter'], 'active') + + def test_process_query_params_with_serializer_and_ivanov_session(self): + """ + Проверка, что сессия Ивана доступна в контексте + Используем get_ivanov_session() для проверки + """ + + ivanov_session = self.get_i_ivanov_session() + request = self.create_request( + 'GET', + '/test', + query_params={'limit': '20'}, + auth=ivanov_session + ) + self.view_set.request = request + result = self.view_set._process_query_params(QueryParamsSerializer) + + self.assertEqual(result['limit'], 20) + + # Проверяем, что сессия действительно Ивана + self.assertEqual(ivanov_session.token, SessionTestData.I_IVANOV_SESSION_TOKEN) + self.assertEqual(str(ivanov_session.account.id), str(AccountTestData.I_IVANOV_ID)) diff --git a/net/xeaf/rack/tests/core/views/core_view_set_request_context_tests.py b/net/xeaf/rack/tests/core/views/core_view_set_request_context_tests.py new file mode 100644 index 0000000..cfc0161 --- /dev/null +++ b/net/xeaf/rack/tests/core/views/core_view_set_request_context_tests.py @@ -0,0 +1,136 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreViewSetRequestContextTests +""" + +from net.xeaf.rack.core.tests import CoreTestCase +from net.xeaf.rack.core.views import CoreViewSet +from net.xeaf.rack.management.userstory import AccountTestData +from net.xeaf.rack.management.userstory import SessionTestData +from net.xeaf.rack.utils.exceptions import UnauthorizedException + + +class MockViewSetWithAuth(CoreViewSet): + """ + ViewSet для тестов аутентификации + """ + + permission_classes = () + + +class CoreViewSetRequestContextTests(CoreTestCase): + """ + Тесты методов получения сессии и аккаунта + """ + + def setUp(self): + """ + Подготовка к тестированию + """ + + super().setUp() + self.view_set = MockViewSetWithAuth() + + def test_get_request_session_authenticated(self): + """ + Получение сессии для аутентифицированного запроса + """ + + session = self.get_i_ivanov_session() + request = self.create_request('GET', '/test', auth=session) + self.view_set.request = request + result = self.view_set._get_request_session() + + self.assertIsNotNone(result) + # noinspection PyUnresolvedReferences + self.assertEqual(result.token, SessionTestData.I_IVANOV_SESSION_TOKEN) + # noinspection PyUnresolvedReferences + self.assertEqual(str(result.account.id), str(AccountTestData.I_IVANOV_ID)) + + def test_get_request_account_authenticated(self): + """ + Получение аккаунта для аутентифицированного запроса + """ + + session = self.get_i_ivanov_session() + request = self.create_request('GET', '/test', auth=session) + self.view_set.request = request + result = self.view_set._get_request_account() + + self.assertIsNotNone(result) + self.assertEqual(str(result.id), str(AccountTestData.I_IVANOV_ID)) + self.assertEqual(result.username, AccountTestData.I_IVANOV_USERNAME) + + def test_get_request_account_unauthenticated_raises_error(self): + """ + Не аутентифицированный запрос - ошибка UnauthorizedException + """ + + request = self.create_request('GET', '/test') + self.view_set.request = request + + with self.assertRaises(UnauthorizedException): + self.view_set._get_request_account() + + def test_get_request_context_with_auth(self): + """ + Получение контекста запроса с аутентификацией + """ + + session = self.get_i_ivanov_session() + request = self.create_request('GET', '/test', auth=session) + self.view_set.request = request + context = self.view_set._get_request_context() + + self.assertIn('account', context) + self.assertIn('request', context) + self.assertEqual(str(context['account'].id), str(AccountTestData.I_IVANOV_ID)) + self.assertEqual(context['request'], request) + + def test_get_request_context_without_auth(self): + """ + Получение контекста без аутентификации - account = None + """ + + request = self.create_request('GET', '/test') + self.view_set.request = request + context = self.view_set._get_request_context() + + self.assertIn('account', context) + self.assertIsNone(context['account']) + self.assertIn('request', context) + self.assertEqual(context['request'], request) + + def test_get_request_session_petrov_authenticated(self): + """ + Получение сессии Петрова + """ + + session = self.get_p_petrov_session() + request = self.create_request('GET', '/test', auth=session) + self.view_set.request = request + result = self.view_set._get_request_session() + + self.assertIsNotNone(result) + # noinspection PyUnresolvedReferences + self.assertEqual(result.token, SessionTestData.P_PETROV_SESSION_TOKEN) + # noinspection PyUnresolvedReferences + self.assertEqual(str(result.account.id), str(AccountTestData.P_PETROV_ID)) + + def test_get_request_account_petrov_authenticated(self): + """ + Получение аккаунта Петрова + """ + session = self.get_p_petrov_session() + request = self.create_request('GET', '/test', auth=session) + self.view_set.request = request + result = self.view_set._get_request_account() + + self.assertIsNotNone(result) + self.assertEqual(str(result.id), str(AccountTestData.P_PETROV_ID)) + self.assertEqual(result.username, AccountTestData.P_PETROV_USERNAME) diff --git a/net/xeaf/rack/tests/managers/sesson_manager_tests.py b/net/xeaf/rack/tests/managers/sesson_manager_tests.py index 4b5db10..127ff50 100644 --- a/net/xeaf/rack/tests/managers/sesson_manager_tests.py +++ b/net/xeaf/rack/tests/managers/sesson_manager_tests.py @@ -14,7 +14,7 @@ from django.core.exceptions import ImproperlyConfigured from django.test import override_settings from django.utils import timezone -from net.xeaf.rack.core import CoreTestCase +from net.xeaf.rack.core.tests import CoreTestCase from net.xeaf.rack.enums import AuthRejectReason from net.xeaf.rack.management.userstory import AccountTestData from net.xeaf.rack.management.userstory import SessionTestData diff --git a/net/xeaf/rack/tests/models/account_model_tests.py b/net/xeaf/rack/tests/models/account_model_tests.py index c5dd1ce..be393b3 100644 --- a/net/xeaf/rack/tests/models/account_model_tests.py +++ b/net/xeaf/rack/tests/models/account_model_tests.py @@ -10,7 +10,7 @@ from django.db.utils import IntegrityError -from net.xeaf.rack.core import CoreTestCase +from net.xeaf.rack.core.tests import CoreTestCase from net.xeaf.rack.management.userstory import AccountTestData from net.xeaf.rack.models import AccountModel diff --git a/net/xeaf/rack/tests/models/session_model_tests.py b/net/xeaf/rack/tests/models/session_model_tests.py index 50b6c40..e156a2d 100644 --- a/net/xeaf/rack/tests/models/session_model_tests.py +++ b/net/xeaf/rack/tests/models/session_model_tests.py @@ -11,7 +11,7 @@ from django.db.utils import IntegrityError from django.utils import timezone -from net.xeaf.rack.core import CoreTestCase +from net.xeaf.rack.core.tests import CoreTestCase from net.xeaf.rack.management.userstory import AccountTestData from net.xeaf.rack.management.userstory import SessionTestData from net.xeaf.rack.models import SessionModel diff --git a/net/xeaf/rack/tests/utils/auth/expiring_token_authentication_tests.py b/net/xeaf/rack/tests/utils/auth/expiring_token_authentication_tests.py index 34c93e2..9dc0d32 100644 --- a/net/xeaf/rack/tests/utils/auth/expiring_token_authentication_tests.py +++ b/net/xeaf/rack/tests/utils/auth/expiring_token_authentication_tests.py @@ -16,7 +16,7 @@ from rest_framework.parsers import JSONParser from rest_framework.request import Request from rest_framework.test import APIRequestFactory -from net.xeaf.rack.core import CoreTestCase +from net.xeaf.rack.core.tests import CoreTestCase from net.xeaf.rack.management.userstory import AccountTestData from net.xeaf.rack.management.userstory import SessionTestData from net.xeaf.rack.models import AccountModel diff --git a/net/xeaf/rack/utils/__init__.py b/net/xeaf/rack/utils/__init__.py index 0dc1a9d..c3f1c7a 100644 --- a/net/xeaf/rack/utils/__init__.py +++ b/net/xeaf/rack/utils/__init__.py @@ -9,3 +9,4 @@ """ from .date_utils import DateUtils +from .model_utils import ModelUtils diff --git a/net/xeaf/rack/utils/model_utils.py b/net/xeaf/rack/utils/model_utils.py new file mode 100644 index 0000000..dfe8e45 --- /dev/null +++ b/net/xeaf/rack/utils/model_utils.py @@ -0,0 +1,53 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ModelUtils +""" + +from typing import Any + +from django.db.models import Model +from django.forms import model_to_dict + + +class ModelUtils: + """ + Реализует служебные функции работы с моделями данных + """ + + @classmethod + def concat_data(cls, data: dict[str, Any] | Model, target: dict[str, Any], ) -> None: + """ + Объединяет данные + + :param data: Источник данных + :param target: Приемник + """ + + # Получаем объект данных + if isinstance(data, Model): + source = model_to_dict(data) + else: + source = data + + # noinspection PyUnresolvedReferences + for key, value in source.items(): + if key not in target: + target[key] = value + + @classmethod + def update_data(cls, instance: dict[str, Any] | Model, validated_data: dict[str, Any]) -> None: + """ + Обновляет данные объекта + + :param instance: Объект + :param validated_data: Данные для обновления + """ + + for attr, value in validated_data.items(): + if hasattr(instance, attr): + setattr(instance, attr, value) diff --git a/wsgiapp/settings.py b/wsgiapp/settings.py index 5f5a01c..0b402a9 100644 --- a/wsgiapp/settings.py +++ b/wsgiapp/settings.py @@ -37,6 +37,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', 'net.xeaf.rack', ] @@ -117,3 +118,11 @@ USE_TZ = True # https://docs.djangoproject.com/en/6.0/howto/static-files/ STATIC_URL = 'static/' + +REST_FRAMEWORK = { + 'DEFAULT_PARSER_CLASSES': [ + 'rest_framework.parsers.JSONParser', + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser', + ], +}