diff --git a/.env.example b/.env.example index 05227c8e..422e4cbb 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,5 @@ TASK_SERIALIZER= TIMEZONE= YOOKASSA_SHOP_ID= -YOOKASSA_SECRET_KEY= \ No newline at end of file +YOOKASSA_SECRET_KEY= +YOOKASSA_RETURN_URL= \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..14514db9 --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +# ------------------------------------- INIT ---------------------------------------- +.PHONY: init +init: +# Установка виртуального окружения. + python -m venv venv +# Активация виртуального окружения. + source venv/bin/activate +# Установка зависимостей проекта. + pip install -r requirements.txt +# ----------------------------------------------------------------------------------- + + +# ----------------------------- MIGRATIONS AND STATIC ------------------------------- +# Применить миграции и отправить. +.PHONY: migrations +migrations: + python ./manage.py makemigrations + python ./manage.py migrate + +# Сбор статик файлов. +.PHONY: static +static: + python ./manage.py collectstatic +# ----------------------------------------------------------------------------------- + + +# ----------------------------------- LOAD DATA ------------------------------------- +# Загрузить данные в базу данных. +.PHONY: load +load: + python ./manage.py loaddata fixtures/categories.json + python ./manage.py loaddata fixtures/providers.json + python ./manage.py loaddata fixtures/products.json + python ./manage.py loaddata fixtures/products_description.json + python ./manage.py loaddata fixtures/products_feature.json + python ./manage.py loaddata fixtures/products_images.json +# ----------------------------------------------------------------------------------- + + +# ------------------------------------- SUPERUSER ----------------------------------- +# Создание суперпользователя. +.PHONY: createsuperuser +createsuperuser: + python ./manage.py createsuperuser +# ----------------------------------------------------------------------------------- + + +# ------------------------------ RUN AND STOP SERVER -------------------------------- +# Запуск сервера разработки. +.PHONY: run +run: + python manage.py runserver + +# Остановка сервера разработки. +.PHONY: stop +stop: + pkill -f "python ./manage.py runserver" +# ----------------------------------------------------------------------------------- + + +# ------------------------------------- CELERY -------------------------------------- +# Запустить Celery. Убедитесь в том, что у вас запущен Redis (на WSL2, если +# у вас Windows) и RabbitMQ на вашем локальном хосте! +.PHONY: celery +celery: + celery --app=config worker --loglevel=info --pool=solo + +# Запустить Celery Beat для резервной копии Базы Данных. +.PHONY: beat +beat: + celery -A config beat -l info +# ----------------------------------------------------------------------------------- \ No newline at end of file diff --git a/api/urls.py b/api/urls.py index c427b058..40a17cf5 100644 --- a/api/urls.py +++ b/api/urls.py @@ -4,6 +4,7 @@ from carts.urls import urlpatterns as cart_urls from delivers.urls import urlpatterns as deliver_urls from orders.urls import urlpatterns as order_urls +from payments.urls import urlpatterns as payments_urls app_name = 'api' @@ -16,3 +17,4 @@ urlpatterns += cart_urls urlpatterns += deliver_urls urlpatterns += order_urls +urlpatterns += payments_urls diff --git a/common/views/mixins.py b/common/views/mixins.py index dacfe294..09439f02 100644 --- a/common/views/mixins.py +++ b/common/views/mixins.py @@ -2,6 +2,7 @@ from djoser.views import UserViewSet from rest_framework import mixins +from rest_framework.generics import CreateAPIView from rest_framework.permissions import AllowAny from rest_framework.viewsets import GenericViewSet @@ -67,6 +68,11 @@ class ExtendedUserViewSet(ExtendedView, UserViewSet): pass +class ExtendedCreateAPIView(ExtendedView, CreateAPIView): + """Расширенное представление для создания.""" + pass + + class ListViewSet(ExtendedGenericViewSet, mixins.ListModelMixin): """ Класс включающий базовый набор поведения generic view и включающий diff --git a/config/settings.py b/config/settings.py index 9ee7a027..d88849c6 100644 --- a/config/settings.py +++ b/config/settings.py @@ -44,6 +44,7 @@ ] MIDDLEWARE = [ + 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -52,11 +53,10 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + # package middlewares 'corsheaders.middleware.CorsMiddleware', 'crum.CurrentRequestUserMiddleware', - - # package middlewares - 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'request_logging.middleware.LoggingMiddleware', # 'customers.middleware.ActiveUserMiddleware', ] @@ -171,8 +171,8 @@ ], 'SWAGGER_UI_SETTINGS': { - 'DeepLinking': True, - 'DisplayOperationId': True, + 'deepLinking': True, + 'displayOperationId': True, 'syntaxHighlight.active': True, 'syntaxHighlight.theme': 'arta', 'defaultModelsExpandDepth': -1, @@ -297,6 +297,8 @@ YOOKASSA_RETURN_URL = env.str(var='YOOKASSA_RETURN_URL') # endregion ------------------------------------------------------------------------- + +# region ------------------------- DJANGO DEBUGGER ---------------------------------- if DEBUG: # region ------------------------- SENTRY --------------------------------------- sentry_sdk.init( @@ -311,9 +313,63 @@ ) # endregion --------------------------------------------------------------------- + INTERNAL_IPS = [ + '127.0.0.1', + ] +# endregion ------------------------------------------------------------------------- -# region ------------------------- DJANGO DEBUGGER ---------------------------------- -INTERNAL_IPS = [ - '127.0.0.1', -] + +# region ------------------------------- LOGGING ------------------------------------ +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {message}', + 'style': '{', + }, + 'logstash': { + 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s', + }, + 'json': { + '()': 'pythonjsonlogger.jsonlogger.JsonFormatter', + 'format': '%(asctime)s %(levelname)s %(message)s', + 'json_indent': None, + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + 'logstash': { + 'level': 'INFO', + 'formatter': 'json', + 'class': 'logstash.TCPLogstashHandler', + 'host': 'localhost', + 'port': 50000, # Значение по умолчанию: 5959 + 'version': 1, + 'message_type': 'django', + 'fqdn': False, # Полное доменное имя. Значение по умолчанию: false. + 'tags': ['django.request'], # Список тегов. По умолчанию: None. + }, + + }, + 'loggers': { + 'django.request': { + 'handlers': ['console', 'logstash'], + 'level': 'DEBUG', + 'propagate': False, + }, + 'request_logging': { + 'handlers': ['console', 'logstash'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} # endregion ------------------------------------------------------------------------- diff --git a/orders/models/orders.py b/orders/models/orders.py index 6d582d74..846901b5 100644 --- a/orders/models/orders.py +++ b/orders/models/orders.py @@ -25,7 +25,7 @@ class Order(BaseModel): class Status(models.TextChoices): """Статус заказа.""" CREATE = 'CR', _('Заказ создан') - WORK = 'WO', _('В работе.') + WORK = 'WO', _('В работе') COMPLETED = 'CO', _('Завершенный') CANCELLED = 'CA', _('Отмененный') diff --git a/orders/views/orders.py b/orders/views/orders.py index 10dd992d..b8949474 100644 --- a/orders/views/orders.py +++ b/orders/views/orders.py @@ -6,7 +6,6 @@ from django.db import transaction from drf_spectacular.utils import extend_schema_view, extend_schema from rest_framework import permissions, status -from rest_framework.decorators import action from rest_framework.response import Response from rest_framework_simplejwt import authentication @@ -14,7 +13,6 @@ from ..models.orders import Order from ..serializers.api import orders as orders_s from ..services.orders import OrderCreateService -from payments.services.webhooks import PaymentConfirmWebHookService if TYPE_CHECKING: from django.db.models import QuerySet @@ -28,10 +26,6 @@ summary='Создать заказ', tags=['Заказ'], ), - payment_confirmation=extend_schema( - summary='Обработать платеж с помощью WebHook', - tags=['Заказ'], - ), ) class OrderMakingViewSet(mixins.CreateViewSet): """Представление оформления заказа.""" @@ -64,21 +58,6 @@ def create(self, request: Request, *args: None, **kwargs: None) -> Response: status=status.HTTP_201_CREATED, ) - @action(methods=['POST'], detail=False) - def payment_confirmation(self, request: Request) -> Response: - """Обработка платежа с помощью webhook-а.""" - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - with transaction.atomic(): - serializer.save() - payment_confirm_webhook = PaymentConfirmWebHookService(request=request) - payment_confirm_webhook.execute() - - return Response( - data={'answer': 'Подтверждение оплаты прошло успешно!'}, - status=status.HTTP_200_OK, - ) - @extend_schema_view( partial_update=extend_schema( diff --git a/payments/serializers/api/payments.py b/payments/serializers/api/payments.py index 27bb71c3..614a0b33 100644 --- a/payments/serializers/api/payments.py +++ b/payments/serializers/api/payments.py @@ -1,21 +1,10 @@ from rest_framework import serializers +from payments.models.payments import OrderPayment -class PaymentConfirmWebHookSerializer(serializers.Serializer): - """ - Сериализатор подтверждения платежа с помощью WebHook-а. - - Атрибуты: - * `product` (PrimaryKeyRelatedField): товар. - * `quantity` (IntegerField): количество одного товара. - """ - id = serializers.UUIDField( - required=True, - label='Идентификатор webhook', - ) - status = serializers.CharField( - label='Статус платежа', - default='succeeded', - read_only=True, - ) +class EmptyPaymentSerializer(serializers.ModelSerializer): + """Пустой сериализатор для подтверждения заказа с помощью webhook-а.""" + class Meta: + model = OrderPayment + fields = ('order_id',) diff --git a/payments/services/payments.py b/payments/services/payments.py index eb3fa482..fbeca697 100644 --- a/payments/services/payments.py +++ b/payments/services/payments.py @@ -96,7 +96,7 @@ def __create_and_get_payment_with_yookassa(self) -> None: 'type': "redirect", 'return_url': YOOKASSA_RETURN_URL, }, - 'capture': True, + 'capture': False, 'description': f'Оплата заказа на {self.__price} руб.', 'metadata': { 'order_id': self.__order.pk, @@ -113,9 +113,12 @@ def __create_and_get_payment_with_yookassa(self) -> None: detail='Ошибка на стороне Yookassa при создании платежа', code=error, ) - def __add_and_save_payment_id(self) -> None: - """Добавить и сохранить `id` платежа в таблицу `OrderPayment`.""" + def __add_payment_id(self) -> None: + """Добавить `id` платежа в таблицу `OrderPayment`.""" self._order_payment.payment_id = self._payment_response.id + + def __save_payment_id(self) -> None: + """Сохранить `id` платежа в таблицу `OrderPayment`.""" self._order_payment.save() def execute_payment_and_get_address(self) -> str: @@ -127,5 +130,6 @@ def execute_payment_and_get_address(self) -> str: self.__create_payment() self._setting_an_account() self.__create_and_get_payment_with_yookassa() - self.__add_and_save_payment_id() + self.__add_payment_id() + self.__save_payment_id() return self._payment_response.confirmation.confirmation_url diff --git a/payments/services/webhooks.py b/payments/services/webhooks.py index 064a805f..52044581 100644 --- a/payments/services/webhooks.py +++ b/payments/services/webhooks.py @@ -10,6 +10,7 @@ from yookassa import Payment from yookassa.domain.notification import PaymentWebhookNotification +from orders.models.orders import Order from .payments import _PaymentBaseService from ..models.payments import OrderPayment @@ -97,6 +98,10 @@ def __check_payment_status_with_get_request(self) -> None: def __is_status_succeeded(self) -> None: """Является ли статус успешным.""" if not self._payment_response.status == 'succeeded': + logger.error( + msg={f'Ошибка на стороне Yookassa. Платежа {self.__payment_id} ' + f'не переведен в статус succeeded': ParseError} + ) raise ParseError( f'Ошибка на стороне Yookassa. Платежа {self.__payment_id}' f' не переведен в статус succeeded' @@ -108,6 +113,12 @@ def __update_status_payment(self) -> None: is_paid=OrderPayment.Status.PAID, ) + def __update_status_order(self) -> None: + """Обновить статус заказа.""" + Order.objects.filter(id=self._order_payment.pk).update( + order_status=Order.Status.WORK, + ) + def execute(self) -> None: """Выполнить обработку webhook-а.""" self._setting_an_account() @@ -117,5 +128,7 @@ def execute(self) -> None: self.__is_such_payment_in_database() self.__get_current_payment() self.__confirm_payment() + self.__check_payment_status_with_get_request() self.__is_status_succeeded() self.__update_status_payment() + self.__update_status_order() diff --git a/payments/urls.py b/payments/urls.py new file mode 100644 index 00000000..7b82d124 --- /dev/null +++ b/payments/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include + +from .views import payments + +urlpatterns = [ + path('orders/payment_confirmation/', payments.PaymentConfirmationAPIView.as_view(), name='payment_confirmation') +] + + diff --git a/payments/views/__init__.py b/payments/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/views/payments.py b/payments/views/payments.py new file mode 100644 index 00000000..98e79079 --- /dev/null +++ b/payments/views/payments.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import transaction +from drf_spectacular.utils import extend_schema_view, extend_schema, OpenApiResponse +from rest_framework import permissions, status +from rest_framework.response import Response + +from common.views.mixins import ExtendedCreateAPIView +from ..services.webhooks import PaymentConfirmWebHookService +from ..serializers.api import payments as payment_s + +if TYPE_CHECKING: + from rest_framework.request import Request + + +@extend_schema_view( + post=extend_schema( + responses={ + status.HTTP_200_OK: OpenApiResponse( + description='Подтверждение оплаты прошло успешно!' + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description='Плохой запрос' + ) + }, + summary='Обработать платеж с помощью WebHook', + tags=['Заказ'], + ), +) +class PaymentConfirmationAPIView(ExtendedCreateAPIView): + """Представление подтверждения платежа.""" + permission_classes = (permissions.AllowAny,) + serializer_class = payment_s.EmptyPaymentSerializer + + def post(self, request: Request, *args: None, **kwargs: None) -> Response: + """Обработка платежа с помощью webhook-а.""" + with transaction.atomic(): + payment_confirm_webhook = PaymentConfirmWebHookService(request=request) + payment_confirm_webhook.execute() + return Response( + data={'answer': 'Подтверждение оплаты прошло успешно!'}, + status=status.HTTP_200_OK, + ) diff --git a/requirements.txt b/requirements.txt index 6dabbc2c..a0be1d3b 100644 Binary files a/requirements.txt and b/requirements.txt differ