From 3704949225d3bd9d428b177225921f3847429d94 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Thu, 9 Nov 2023 10:47:36 -0300 Subject: [PATCH 01/16] Create Vtex-Apptype, service and client. --- marketplace/clients/vtex/client.py | 16 ++ marketplace/core/serializers.py | 3 + marketplace/core/types/externals/base.py | 5 - .../core/types/externals/vtex/__init__.py | 0 .../core/types/externals/vtex/serializers.py | 24 +++ .../types/externals/vtex/tests/__init__.py | 0 .../types/externals/vtex/tests/test_views.py | 185 ++++++++++++++++++ marketplace/core/types/externals/vtex/type.py | 14 ++ .../core/types/externals/vtex/views.py | 46 +++++ marketplace/services/__init__.py | 0 marketplace/services/vtex/__init__.py | 0 marketplace/services/vtex/public/__init__.py | 0 .../services/vtex/public/products/__init__.py | 0 .../public/products/products_vtex_service.py | 57 ++++++ .../vtex/public/products/tests/__init__.py | 0 .../tests/test_products_vtex_service.py | 74 +++++++ marketplace/settings.py | 2 + 17 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 marketplace/clients/vtex/client.py create mode 100644 marketplace/core/types/externals/vtex/__init__.py create mode 100644 marketplace/core/types/externals/vtex/serializers.py create mode 100644 marketplace/core/types/externals/vtex/tests/__init__.py create mode 100644 marketplace/core/types/externals/vtex/tests/test_views.py create mode 100644 marketplace/core/types/externals/vtex/type.py create mode 100644 marketplace/core/types/externals/vtex/views.py create mode 100644 marketplace/services/__init__.py create mode 100644 marketplace/services/vtex/__init__.py create mode 100644 marketplace/services/vtex/public/__init__.py create mode 100644 marketplace/services/vtex/public/products/__init__.py create mode 100644 marketplace/services/vtex/public/products/products_vtex_service.py create mode 100644 marketplace/services/vtex/public/products/tests/__init__.py create mode 100644 marketplace/services/vtex/public/products/tests/test_products_vtex_service.py diff --git a/marketplace/clients/vtex/client.py b/marketplace/clients/vtex/client.py new file mode 100644 index 00000000..9a728629 --- /dev/null +++ b/marketplace/clients/vtex/client.py @@ -0,0 +1,16 @@ +from marketplace.clients.base import RequestClient + + +class VtexPublicClient(RequestClient): + def list_products(self, domain): + url = f"https://{domain}/api/catalog_system/pub/products/search/" + response = self.make_request(url, method="GET") # TODO: list all paginate products + return response + + def check_domain(self, domain): + try: + url = f"https://{domain}/api/catalog_system/pub/products/search/" + response = self.make_request(url, method="GET") + return response.status_code == 206 + except Exception: + return False diff --git a/marketplace/core/serializers.py b/marketplace/core/serializers.py index 6a674d1c..197a7029 100644 --- a/marketplace/core/serializers.py +++ b/marketplace/core/serializers.py @@ -30,6 +30,9 @@ def __init__(self, *args, **kwargs): self.type_class = self.context.get("view").type_class def create(self, validated_data): + if self.type_class and hasattr(self.type_class, "platform"): + validated_data["platform"] = self.type_class.platform + validated_data.pop("modified_by", None) return super().create(validated_data) diff --git a/marketplace/core/types/externals/base.py b/marketplace/core/types/externals/base.py index 2a2280a8..856ed39b 100644 --- a/marketplace/core/types/externals/base.py +++ b/marketplace/core/types/externals/base.py @@ -5,8 +5,3 @@ class ExternalAppType(AppType): platform = App.PLATFORM_WENI_FLOWS category = AppType.CATEGORY_EXTERNAL - - # TODO: uncomment this method - # @abstractproperty - # def flows_type_code(self) -> str: - # pass diff --git a/marketplace/core/types/externals/vtex/__init__.py b/marketplace/core/types/externals/vtex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/core/types/externals/vtex/serializers.py b/marketplace/core/types/externals/vtex/serializers.py new file mode 100644 index 00000000..e35c4c05 --- /dev/null +++ b/marketplace/core/types/externals/vtex/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + +from marketplace.core.serializers import AppTypeBaseSerializer +from marketplace.applications.models import App + + +class VtexDomainSerializer(serializers.Serializer): + domain = serializers.CharField(required=True) + + +class VtexSerializer(AppTypeBaseSerializer): + class Meta: + model = App + fields = ( + "code", + "uuid", + "project_uuid", + "platform", + "config", + "created_by", + "created_on", + "modified_by", + ) + read_only_fields = ("code", "uuid", "platform") diff --git a/marketplace/core/types/externals/vtex/tests/__init__.py b/marketplace/core/types/externals/vtex/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/core/types/externals/vtex/tests/test_views.py b/marketplace/core/types/externals/vtex/tests/test_views.py new file mode 100644 index 00000000..cd9c5fe0 --- /dev/null +++ b/marketplace/core/types/externals/vtex/tests/test_views.py @@ -0,0 +1,185 @@ +import uuid + +from unittest.mock import patch, PropertyMock + +from django.urls import reverse +from rest_framework import status + +from marketplace.core.tests.base import APIBaseTestCase +from marketplace.accounts.models import ProjectAuthorization +from marketplace.applications.models import App + +from ..views import VtexViewSet +from ..type import VtexType + + +apptype = VtexType() + + +class CreateVtexAppTestCase(APIBaseTestCase): + url = reverse("vtex-app-list") + view_class = VtexViewSet + + @property + def view(self): + return self.view_class.as_view(APIBaseTestCase.ACTION_CREATE) + + def setUp(self): + super().setUp() + project_uuid = str(uuid.uuid4()) + self.body = {"project_uuid": project_uuid} + + self.user_authorization = self.user.authorizations.create( + project_uuid=project_uuid, role=ProjectAuthorization.ROLE_CONTRIBUTOR + ) + + def test_request_ok(self): + response = self.request.post(self.url, self.body) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_create_app_without_project_uuid(self): + self.body.pop("project_uuid") + response = self.request.post(self.url, self.body) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_create_app_platform(self): + response = self.request.post(self.url, self.body) + self.assertEqual(response.json["platform"], App.PLATFORM_WENI_FLOWS) + + def test_create_app_without_permission(self): + self.user_authorization.delete() + response = self.request.post(self.url, self.body) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class RetrieveVtexAppTestCase(APIBaseTestCase): + view_class = VtexViewSet + + def setUp(self): + super().setUp() + + self.app = apptype.create_app( + created_by=self.user, project_uuid=str(uuid.uuid4()) + ) + self.user_authorization = self.user.authorizations.create( + project_uuid=self.app.project_uuid + ) + self.user_authorization.set_role(ProjectAuthorization.ROLE_ADMIN) + self.url = reverse("vtex-app-detail", kwargs={"uuid": self.app.uuid}) + + @property + def view(self): + return self.view_class.as_view(APIBaseTestCase.ACTION_RETRIEVE) + + def test_request_ok(self): + response = self.request.get(self.url, uuid=self.app.uuid) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_retrieve_app_data(self): + response = self.request.get(self.url, uuid=self.app.uuid) + self.assertIn("uuid", response.json) + self.assertIn("project_uuid", response.json) + self.assertIn("platform", response.json) + self.assertIn("created_on", response.json) + self.assertEqual(response.json["config"], {}) + + +class MockVtexProductsService: + def __init__(self, *args, **kwargs): + pass + + def is_domain_valid(self, domain): + valid_domains = ["valid.domain.com"] + return domain in valid_domains + + def configure(self, app, domain): + if not self.is_domain_valid(domain): + raise ValueError("The domain provided is invalid.") + + app.configured = True + app.config['domain'] = domain + app.save() + return app + + +class SetUpView(APIBaseTestCase): + view_class = VtexViewSet + + def setUp(self): + super().setUp() + self.app = apptype.create_app( + created_by=self.user, project_uuid=str(uuid.uuid4()) + ) + self.user_authorization = self.user.authorizations.create( + project_uuid=self.app.project_uuid + ) + self.user_authorization.set_role(ProjectAuthorization.ROLE_ADMIN) + self.url = reverse("vtex-app-configure", kwargs={"uuid": self.app.uuid}) + + @property + def view(self): + return self.view_class.as_view({"patch": "configure"}) + + +class SetUpService(SetUpView): + def setUp(self): + super().setUp() + + # Mock service + mock_service = MockVtexProductsService() + patcher = patch.object( + self.view_class, "service", PropertyMock(return_value=mock_service) + ) + self.addCleanup(patcher.stop) + patcher.start() + + +class ConfigureVtexAppTestCase(SetUpService): + def test_configure_app_success(self): + body = {"domain": "valid.domain.com"} + response = self.request.patch(self.url, body, uuid=self.app.uuid) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_configure_app_failure(self): + body = {"domain": "invalid.domain.com"} + response = self.request.patch(self.url, body, uuid=self.app.uuid) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class DeleteVtexAppTestCase(APIBaseTestCase): + view_class = VtexViewSet + + @property + def view(self): + return self.view_class.as_view(APIBaseTestCase.ACTION_DESTROY) + + def setUp(self): + super().setUp() + self.app = apptype.create_app( + created_by=self.user, project_uuid=str(uuid.uuid4()) + ) + self.user_authorization = self.user.authorizations.create( + project_uuid=self.app.project_uuid + ) + self.user_authorization.set_role(ProjectAuthorization.ROLE_ADMIN) + self.url = reverse("vtex-app-detail", kwargs={"uuid": self.app.uuid}) + + def test_delete_app_plataform(self): + response = self.request.delete(self.url, uuid=self.app.uuid) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(App.objects.filter(uuid=self.app.uuid).exists()) + + def test_delete_app_with_wrong_project_uuid(self): + response = self.request.delete(self.url, uuid=str(uuid.uuid4())) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_app_without_autorization(self): + self.user_authorization.set_role(ProjectAuthorization.ROLE_NOT_SETTED) + response = self.request.delete(self.url, uuid=self.app.uuid) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_release_external_service(self): + response = self.request.delete(self.url, uuid=self.app.uuid) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(App.objects.filter(uuid=self.app.uuid).exists()) diff --git a/marketplace/core/types/externals/vtex/type.py b/marketplace/core/types/externals/vtex/type.py new file mode 100644 index 00000000..7d59d446 --- /dev/null +++ b/marketplace/core/types/externals/vtex/type.py @@ -0,0 +1,14 @@ +from ..base import ExternalAppType +from .views import VtexViewSet + + +class VtexType(ExternalAppType): + view_class = VtexViewSet + code = "vtex" + flows_type_code = None + name = "Vtex" + description = "vtex.data.description" + summary = "vtex.data.summary" + bg_color = None + developer = "Weni" + config_design = "" diff --git a/marketplace/core/types/externals/vtex/views.py b/marketplace/core/types/externals/vtex/views.py new file mode 100644 index 00000000..7c7144fe --- /dev/null +++ b/marketplace/core/types/externals/vtex/views.py @@ -0,0 +1,46 @@ +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework import status + +from marketplace.core.types.externals.vtex.serializers import VtexDomainSerializer +from marketplace.core.types.externals.vtex.serializers import VtexSerializer +from marketplace.core.types import views +from marketplace.clients.vtex.client import VtexPublicClient +from marketplace.services.vtex.public.products.products_vtex_service import VtexProductsService + + +class VtexViewSet(views.BaseAppTypeViewSet): + serializer_class = VtexSerializer + + service_class = VtexProductsService + client_class = VtexPublicClient + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._service = None + + @property + def service(self): # pragma: no cover + if not self._service: + self._service = self.service_class(self.client_class()) + return self._service + + def perform_create(self, serializer): + serializer.save(code=self.type_class.code) + + @action(detail=True, methods=["PATCH"]) + def configure(self, request, *args, **kwargs): + app_instance = self.get_object() + serializer = VtexDomainSerializer(data=request.data) + if serializer.is_valid(raise_exception=True): + domain = serializer.validated_data["domain"] + try: + updated_app = self.service.configure(app_instance, domain) + return Response(data=self.get_serializer(updated_app).data) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/marketplace/services/__init__.py b/marketplace/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/__init__.py b/marketplace/services/vtex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/public/__init__.py b/marketplace/services/vtex/public/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/public/products/__init__.py b/marketplace/services/vtex/public/products/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/public/products/products_vtex_service.py b/marketplace/services/vtex/public/products/products_vtex_service.py new file mode 100644 index 00000000..10df1d22 --- /dev/null +++ b/marketplace/services/vtex/public/products/products_vtex_service.py @@ -0,0 +1,57 @@ +""" +Service for interfacing with VTEX API. + +This service deals with communication with the public API of the VTEX platform, enabling interaction +with various features like product list and domain verification required to +integrating VTEX into the market. + +Attributes: + client (ClientType): An instance of a client responsible for making API requests to the VTEX platform. + +Methods: + is_domain_valid(domain): Validates the VTEX domain by checking its API response. + + list_all_products(domain): Lists all products from the VTEX store of the given domain. + + update_config(app, key, value): Updates the configuration for the given App by setting a value for a specific key. + + configure(app, domain): Configures the given App with the VTEX domain if valid and marks the app as configured. + +Raises: + ValueError: If the provided domain is invalid during the configuration process. +""" + + +class VtexProductsService: + def __init__(self, client): + self.client = client + + # ================================ + # Public Methods + # ================================ + + def is_domain_valid(self, domain): + return self.client.check_domain(domain) + + def list_all_products(self, domain): + self._check_is_valid_domain(domain) + return self.client.list_products(domain) + + def configure(self, app, domain): + self._check_is_valid_domain(domain) + updated_app = self._update_config(app, "domain", domain) + updated_app.configured = True + updated_app.save() + return updated_app + + # ================================ + # Private Methods + # ================================ + + def _update_config(self, app, key, value): + app.config[key] = value + return app + + def _check_is_valid_domain(self, domain): + if not self.is_domain_valid(domain): + raise ValueError("The domain provided is invalid.") diff --git a/marketplace/services/vtex/public/products/tests/__init__.py b/marketplace/services/vtex/public/products/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/public/products/tests/test_products_vtex_service.py b/marketplace/services/vtex/public/products/tests/test_products_vtex_service.py new file mode 100644 index 00000000..c456817e --- /dev/null +++ b/marketplace/services/vtex/public/products/tests/test_products_vtex_service.py @@ -0,0 +1,74 @@ +import uuid + +from django.test import TestCase +from django.contrib.auth import get_user_model + +from marketplace.applications.models import App +from marketplace.services.vtex.public.products.products_vtex_service import ( + VtexProductsService, +) + + +User = get_user_model() + + +class MockClient: + def list_products(self, domain): + return [ + { + "productId": "6494", + "productName": "Limão Taiti", + "brand": "Sem Marca", + "other_fields": "...", + } + ] + + def check_domain(self, domain): + return domain == "valid.domain.com" + + +class TestVtexPublicProducts(TestCase): + def setUp(self): + user, _bool = User.objects.get_or_create(email="user-fbaservice@marketplace.ai") + self.service = VtexProductsService(client=MockClient()) + self.app = App.objects.create( + code="vtex", + config={}, + created_by=user, + project_uuid=str(uuid.uuid4()), + platform=App.PLATFORM_WENI_FLOWS, + ) + + # ================================ + # Valid Domain + # ================================ + + def test_is_domain_valid(self): + response = self.service.is_domain_valid("valid.domain.com") + self.assertTrue(response) + + def test_configure_valid(self): + response = self.service.configure(self.app, "valid.domain.com") + self.assertEqual(response.config, {"domain": "valid.domain.com"}) + + def test_list_all_products(self): + response = self.service.list_all_products("valid.domain.com") + self.assertEqual(len(response), 1) + + # ================================ + # Invalid Domain + # ================================ + + def test_list_all_products_invalid_domain(self): + with self.assertRaises(ValueError) as context: + self.service.list_all_products("invalid.domain.com") + self.assertTrue("The domain provided is invalid." in str(context.exception)) + + def test_configure_invalid_domain(self): + with self.assertRaises(ValueError) as context: + self.service.configure(self.app, "invalid.domain.com") + self.assertTrue("The domain provided is invalid." in str(context.exception)) + + def test_is_domain_invalid_domain(self): + response = self.service.is_domain_valid("invalid.domain.com") + self.assertFalse(response) diff --git a/marketplace/settings.py b/marketplace/settings.py index 97c25753..d00dce81 100644 --- a/marketplace/settings.py +++ b/marketplace/settings.py @@ -276,6 +276,7 @@ # Externals APPTYPE_OMIE_PATH = "externals.omie.type.OmieType" APPTYPE_CHATGPT_PATH = "externals.chatgpt.type.ChatGPTType" +APPTYPE_VTEX_PATH = "externals.vtex.type.VtexType" APPTYPES_CLASSES = [ APPTYPE_WENI_WEB_CHAT_PATH, @@ -288,6 +289,7 @@ APPTYPE_OMIE_PATH, APPTYPE_GENERIC_CHANNEL_PATH, APPTYPE_CHATGPT_PATH, + APPTYPE_VTEX_PATH, ] # These conditions avoid dependence between apptypes, From 57cda416232964ec1a85d03d8d4a455a61357fe9 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Thu, 9 Nov 2023 10:55:49 -0300 Subject: [PATCH 02/16] Applying Black --- marketplace/clients/vtex/client.py | 4 +++- marketplace/core/types/externals/vtex/tests/test_views.py | 2 +- marketplace/core/types/externals/vtex/views.py | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/marketplace/clients/vtex/client.py b/marketplace/clients/vtex/client.py index 9a728629..c67651a9 100644 --- a/marketplace/clients/vtex/client.py +++ b/marketplace/clients/vtex/client.py @@ -4,7 +4,9 @@ class VtexPublicClient(RequestClient): def list_products(self, domain): url = f"https://{domain}/api/catalog_system/pub/products/search/" - response = self.make_request(url, method="GET") # TODO: list all paginate products + response = self.make_request( + url, method="GET" + ) # TODO: list all paginate products return response def check_domain(self, domain): diff --git a/marketplace/core/types/externals/vtex/tests/test_views.py b/marketplace/core/types/externals/vtex/tests/test_views.py index cd9c5fe0..889f846c 100644 --- a/marketplace/core/types/externals/vtex/tests/test_views.py +++ b/marketplace/core/types/externals/vtex/tests/test_views.py @@ -98,7 +98,7 @@ def configure(self, app, domain): raise ValueError("The domain provided is invalid.") app.configured = True - app.config['domain'] = domain + app.config["domain"] = domain app.save() return app diff --git a/marketplace/core/types/externals/vtex/views.py b/marketplace/core/types/externals/vtex/views.py index 7c7144fe..66b75fc9 100644 --- a/marketplace/core/types/externals/vtex/views.py +++ b/marketplace/core/types/externals/vtex/views.py @@ -6,7 +6,9 @@ from marketplace.core.types.externals.vtex.serializers import VtexSerializer from marketplace.core.types import views from marketplace.clients.vtex.client import VtexPublicClient -from marketplace.services.vtex.public.products.products_vtex_service import VtexProductsService +from marketplace.services.vtex.public.products.products_vtex_service import ( + VtexProductsService, +) class VtexViewSet(views.BaseAppTypeViewSet): From 544c36fa989547606d11d315d81f7415cc77fe7a Mon Sep 17 00:00:00 2001 From: elitonzky Date: Fri, 17 Nov 2023 16:18:13 -0300 Subject: [PATCH 03/16] Add vtex private client, add listing of all products and search for products by skuID --- marketplace/clients/vtex/client.py | 58 ++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/marketplace/clients/vtex/client.py b/marketplace/clients/vtex/client.py index c67651a9..04928294 100644 --- a/marketplace/clients/vtex/client.py +++ b/marketplace/clients/vtex/client.py @@ -1,14 +1,22 @@ from marketplace.clients.base import RequestClient -class VtexPublicClient(RequestClient): - def list_products(self, domain): - url = f"https://{domain}/api/catalog_system/pub/products/search/" - response = self.make_request( - url, method="GET" - ) # TODO: list all paginate products - return response +class VtexAuthorization(RequestClient): + def __init__(self, app_key, app_token): + self.app_key = app_key + self.app_token = app_token + + def _get_headers(self): + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "X-VTEX-API-AppKey": self.app_key, + "X-VTEX-API-AppToken": self.app_token, + } + return headers + +class VtexCommonClient(RequestClient): def check_domain(self, domain): try: url = f"https://{domain}/api/catalog_system/pub/products/search/" @@ -16,3 +24,39 @@ def check_domain(self, domain): return response.status_code == 206 except Exception: return False + + +class VtexPublicClient(VtexCommonClient): + def search_product_by_sku_id(self, skuid, domain, sellerid=1): + url = f"https://{domain}/api/catalog_system/pub/products/search?fq=skuId:{skuid}&sellerId={sellerid}" + response = self.make_request(url, method="GET") + return response + + +class VtexPrivateClient(VtexAuthorization, VtexCommonClient): + def get_products_sku_ids(self, domain): + all_skus = [] + page_size = 250 + _from = 1 + _to = page_size + + while True: + url = f"https://{domain}/api/catalog_system/pvt/products/GetProductAndSkuIds?_from={_from}&_to={_to}" + headers = self._get_headers() + response = self.make_request(url, method="GET", headers=headers) + + data = response.json().get("data") + if data: + for sku_ids in data.values(): + all_skus.extend(sku_ids) + + total_products = response.json().get("range", {}).get("total", 0) + if _to >= total_products: + break + _from += page_size + _to += page_size + _to = min(_to, total_products) # To avoid overshooting the total count + else: + break + + return all_skus From 30795dd61642719e9d3026f065ee2d3a405575d3 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Thu, 23 Nov 2023 18:03:55 -0300 Subject: [PATCH 04/16] Create Private Vtex Service and Generic Service --- .../migrations/0017_alter_app_platform.py | 18 +++ marketplace/applications/models.py | 2 + marketplace/clients/vtex/client.py | 9 ++ marketplace/core/types/base.py | 2 + .../{externals/vtex => ecommerce}/__init__.py | 0 marketplace/core/types/ecommerce/base.py | 7 + .../vtex/tests => ecommerce/vtex}/__init__.py | 0 .../vtex/serializers.py | 8 +- .../types/ecommerce/vtex/tests/__init__.py | 0 .../vtex/tests/test_views.py | 138 ++++++++---------- .../{externals => ecommerce}/vtex/type.py | 6 +- .../core/types/ecommerce/vtex/views.py | 59 ++++++++ .../core/types/externals/vtex/views.py | 48 ------ marketplace/core/types/views.py | 16 +- marketplace/services/vtex/exceptions.py | 20 +++ marketplace/services/vtex/generic_service.py | 111 ++++++++++++++ marketplace/services/vtex/private/__init__.py | 0 .../vtex/private/products/__init__.py | 0 .../services/vtex/private/products/service.py | 62 ++++++++ .../public/products/products_vtex_service.py | 57 -------- .../services/vtex/public/products/service.py | 61 ++++++++ .../tests/test_products_vtex_service.py | 32 ++-- marketplace/settings.py | 2 +- 23 files changed, 454 insertions(+), 204 deletions(-) create mode 100644 marketplace/applications/migrations/0017_alter_app_platform.py rename marketplace/core/types/{externals/vtex => ecommerce}/__init__.py (100%) create mode 100644 marketplace/core/types/ecommerce/base.py rename marketplace/core/types/{externals/vtex/tests => ecommerce/vtex}/__init__.py (100%) rename marketplace/core/types/{externals => ecommerce}/vtex/serializers.py (69%) create mode 100644 marketplace/core/types/ecommerce/vtex/tests/__init__.py rename marketplace/core/types/{externals => ecommerce}/vtex/tests/test_views.py (70%) rename marketplace/core/types/{externals => ecommerce}/vtex/type.py (70%) create mode 100644 marketplace/core/types/ecommerce/vtex/views.py delete mode 100644 marketplace/core/types/externals/vtex/views.py create mode 100644 marketplace/services/vtex/exceptions.py create mode 100644 marketplace/services/vtex/generic_service.py create mode 100644 marketplace/services/vtex/private/__init__.py create mode 100644 marketplace/services/vtex/private/products/__init__.py create mode 100644 marketplace/services/vtex/private/products/service.py delete mode 100644 marketplace/services/vtex/public/products/products_vtex_service.py create mode 100644 marketplace/services/vtex/public/products/service.py diff --git a/marketplace/applications/migrations/0017_alter_app_platform.py b/marketplace/applications/migrations/0017_alter_app_platform.py new file mode 100644 index 00000000..ae04b279 --- /dev/null +++ b/marketplace/applications/migrations/0017_alter_app_platform.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2023-11-23 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0016_app_configured'), + ] + + operations = [ + migrations.AlterField( + model_name='app', + name='platform', + field=models.CharField(choices=[('IA', 'inteligence-artificial'), ('WF', 'weni-flows'), ('RC', 'rocketchat'), ('VT', 'vtex')], max_length=2), + ), + ] diff --git a/marketplace/applications/models.py b/marketplace/applications/models.py index 873076ac..b8f99cf9 100644 --- a/marketplace/applications/models.py +++ b/marketplace/applications/models.py @@ -21,11 +21,13 @@ class App(AppTypeBaseModel): PLATFORM_IA = "IA" PLATFORM_WENI_FLOWS = "WF" PLATFORM_RC = "RC" + PLATFORM_VTEX = "VT" PLATFORM_CHOICES = ( (PLATFORM_IA, "inteligence-artificial"), (PLATFORM_WENI_FLOWS, "weni-flows"), (PLATFORM_RC, "rocketchat"), + (PLATFORM_VTEX, "vtex"), ) config = models.JSONField(default=dict) diff --git a/marketplace/clients/vtex/client.py b/marketplace/clients/vtex/client.py index 04928294..dad8e10e 100644 --- a/marketplace/clients/vtex/client.py +++ b/marketplace/clients/vtex/client.py @@ -60,3 +60,12 @@ def get_products_sku_ids(self, domain): break return all_skus + + def is_valid_credentials(self, domain): + try: + url = f"https://{domain}/api/catalog_system/pvt/products/GetProductAndSkuIds" + headers = self._get_headers() + response = self.make_request(url, method="GET", headers=headers) + return response.status_code == 200 + except Exception: + return False diff --git a/marketplace/core/types/base.py b/marketplace/core/types/base.py index b886d7a4..5f6a804c 100644 --- a/marketplace/core/types/base.py +++ b/marketplace/core/types/base.py @@ -16,12 +16,14 @@ class AbstractAppType(ABC): CATEGORY_CLASSIFIER = "CF" CATEGORY_TICKETER = "TK" CATEGORY_EXTERNAL = "EXT" + CATEGORY_ECOMMERCE = "ECM" CATEGORY_CHOICES = ( (CATEGORY_CHANNEL, "channel"), (CATEGORY_CLASSIFIER, "classifier"), (CATEGORY_TICKETER, "ticketer"), (CATEGORY_EXTERNAL, "external"), + (CATEGORY_ECOMMERCE, "ecommerce"), ) @abstractproperty diff --git a/marketplace/core/types/externals/vtex/__init__.py b/marketplace/core/types/ecommerce/__init__.py similarity index 100% rename from marketplace/core/types/externals/vtex/__init__.py rename to marketplace/core/types/ecommerce/__init__.py diff --git a/marketplace/core/types/ecommerce/base.py b/marketplace/core/types/ecommerce/base.py new file mode 100644 index 00000000..fd7f42d7 --- /dev/null +++ b/marketplace/core/types/ecommerce/base.py @@ -0,0 +1,7 @@ +from marketplace.core.types.base import AppType +from marketplace.applications.models import App + + +class EcommerceAppType(AppType): + platform = App.PLATFORM_VTEX + category = AppType.CATEGORY_ECOMMERCE diff --git a/marketplace/core/types/externals/vtex/tests/__init__.py b/marketplace/core/types/ecommerce/vtex/__init__.py similarity index 100% rename from marketplace/core/types/externals/vtex/tests/__init__.py rename to marketplace/core/types/ecommerce/vtex/__init__.py diff --git a/marketplace/core/types/externals/vtex/serializers.py b/marketplace/core/types/ecommerce/vtex/serializers.py similarity index 69% rename from marketplace/core/types/externals/vtex/serializers.py rename to marketplace/core/types/ecommerce/vtex/serializers.py index e35c4c05..4d08ceb5 100644 --- a/marketplace/core/types/externals/vtex/serializers.py +++ b/marketplace/core/types/ecommerce/vtex/serializers.py @@ -8,7 +8,13 @@ class VtexDomainSerializer(serializers.Serializer): domain = serializers.CharField(required=True) -class VtexSerializer(AppTypeBaseSerializer): +class VtexSerializer(serializers.Serializer): + domain = serializers.CharField(required=True) + app_key = serializers.CharField(required=True) + app_token = serializers.CharField(required=True) + + +class VtexAppSerializer(AppTypeBaseSerializer): class Meta: model = App fields = ( diff --git a/marketplace/core/types/ecommerce/vtex/tests/__init__.py b/marketplace/core/types/ecommerce/vtex/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/core/types/externals/vtex/tests/test_views.py b/marketplace/core/types/ecommerce/vtex/tests/test_views.py similarity index 70% rename from marketplace/core/types/externals/vtex/tests/test_views.py rename to marketplace/core/types/ecommerce/vtex/tests/test_views.py index 889f846c..94a5291c 100644 --- a/marketplace/core/types/externals/vtex/tests/test_views.py +++ b/marketplace/core/types/ecommerce/vtex/tests/test_views.py @@ -1,34 +1,69 @@ import uuid -from unittest.mock import patch, PropertyMock +from unittest.mock import patch +from unittest.mock import Mock +from unittest.mock import PropertyMock from django.urls import reverse + from rest_framework import status from marketplace.core.tests.base import APIBaseTestCase from marketplace.accounts.models import ProjectAuthorization from marketplace.applications.models import App - -from ..views import VtexViewSet -from ..type import VtexType +from marketplace.core.types.ecommerce.vtex.views import VtexViewSet +from marketplace.core.types.ecommerce.vtex.type import VtexType apptype = VtexType() -class CreateVtexAppTestCase(APIBaseTestCase): - url = reverse("vtex-app-list") +class MockVtexService: + def get_vtex_app_or_error(self, project_uuid): + mock_app = Mock(spec=App) + mock_app.config = {} + return mock_app + + def check_is_valid_credentials(self, credentials): + return True + + def configure(self, app, credentials): + app.configured = True + app.config["api_credentials"] = credentials.to_dict() + app.save() + return app + + +class SetUpService(APIBaseTestCase): view_class = VtexViewSet - @property - def view(self): - return self.view_class.as_view(APIBaseTestCase.ACTION_CREATE) + def setUp(self): + super().setUp() + + # Mock service + self.mock_service = MockVtexService() + patcher = patch.object( + self.view_class, + "service", + new_callable=PropertyMock, + return_value=self.mock_service, + ) + self.addCleanup(patcher.stop) + patcher.start() + + +class CreateVtexAppTestCase(SetUpService): + url = reverse("vtex-app-list") def setUp(self): super().setUp() project_uuid = str(uuid.uuid4()) - self.body = {"project_uuid": project_uuid} - + self.body = { + "project_uuid": project_uuid, + "app_key": "valid-app-key", + "app_token": "valid-app-token", + "domain": "valid.domain.com", + } self.user_authorization = self.user.authorizations.create( project_uuid=project_uuid, role=ProjectAuthorization.ROLE_CONTRIBUTOR ) @@ -37,6 +72,10 @@ def test_request_ok(self): response = self.request.post(self.url, self.body) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + @property + def view(self): + return self.view_class.as_view(APIBaseTestCase.ACTION_CREATE) + def test_create_app_without_project_uuid(self): self.body.pop("project_uuid") response = self.request.post(self.url, self.body) @@ -45,13 +84,24 @@ def test_create_app_without_project_uuid(self): def test_create_app_platform(self): response = self.request.post(self.url, self.body) - self.assertEqual(response.json["platform"], App.PLATFORM_WENI_FLOWS) + self.assertEqual(response.json["platform"], App.PLATFORM_VTEX) def test_create_app_without_permission(self): self.user_authorization.delete() response = self.request.post(self.url, self.body) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_create_app_configuration_exception(self): + original_configure = self.mock_service.configure + self.mock_service.configure = Mock(side_effect=Exception("Configuration error")) + + response = self.request.post(self.url, self.body) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("error", response.data) + self.assertEqual(response.data["error"], "Configuration error") + + self.mock_service.configure = original_configure + class RetrieveVtexAppTestCase(APIBaseTestCase): view_class = VtexViewSet @@ -85,68 +135,6 @@ def test_retrieve_app_data(self): self.assertEqual(response.json["config"], {}) -class MockVtexProductsService: - def __init__(self, *args, **kwargs): - pass - - def is_domain_valid(self, domain): - valid_domains = ["valid.domain.com"] - return domain in valid_domains - - def configure(self, app, domain): - if not self.is_domain_valid(domain): - raise ValueError("The domain provided is invalid.") - - app.configured = True - app.config["domain"] = domain - app.save() - return app - - -class SetUpView(APIBaseTestCase): - view_class = VtexViewSet - - def setUp(self): - super().setUp() - self.app = apptype.create_app( - created_by=self.user, project_uuid=str(uuid.uuid4()) - ) - self.user_authorization = self.user.authorizations.create( - project_uuid=self.app.project_uuid - ) - self.user_authorization.set_role(ProjectAuthorization.ROLE_ADMIN) - self.url = reverse("vtex-app-configure", kwargs={"uuid": self.app.uuid}) - - @property - def view(self): - return self.view_class.as_view({"patch": "configure"}) - - -class SetUpService(SetUpView): - def setUp(self): - super().setUp() - - # Mock service - mock_service = MockVtexProductsService() - patcher = patch.object( - self.view_class, "service", PropertyMock(return_value=mock_service) - ) - self.addCleanup(patcher.stop) - patcher.start() - - -class ConfigureVtexAppTestCase(SetUpService): - def test_configure_app_success(self): - body = {"domain": "valid.domain.com"} - response = self.request.patch(self.url, body, uuid=self.app.uuid) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_configure_app_failure(self): - body = {"domain": "invalid.domain.com"} - response = self.request.patch(self.url, body, uuid=self.app.uuid) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - class DeleteVtexAppTestCase(APIBaseTestCase): view_class = VtexViewSet @@ -179,7 +167,7 @@ def test_delete_app_without_autorization(self): response = self.request.delete(self.url, uuid=self.app.uuid) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_release_external_service(self): + def test_release_ecommerce_service(self): response = self.request.delete(self.url, uuid=self.app.uuid) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(App.objects.filter(uuid=self.app.uuid).exists()) diff --git a/marketplace/core/types/externals/vtex/type.py b/marketplace/core/types/ecommerce/vtex/type.py similarity index 70% rename from marketplace/core/types/externals/vtex/type.py rename to marketplace/core/types/ecommerce/vtex/type.py index 7d59d446..c1a6417a 100644 --- a/marketplace/core/types/externals/vtex/type.py +++ b/marketplace/core/types/ecommerce/vtex/type.py @@ -1,8 +1,8 @@ -from ..base import ExternalAppType +from ..base import EcommerceAppType from .views import VtexViewSet -class VtexType(ExternalAppType): +class VtexType(EcommerceAppType): view_class = VtexViewSet code = "vtex" flows_type_code = None @@ -11,4 +11,4 @@ class VtexType(ExternalAppType): summary = "vtex.data.summary" bg_color = None developer = "Weni" - config_design = "" + config_design = "pre-popup" diff --git a/marketplace/core/types/ecommerce/vtex/views.py b/marketplace/core/types/ecommerce/vtex/views.py new file mode 100644 index 00000000..c831c1ec --- /dev/null +++ b/marketplace/core/types/ecommerce/vtex/views.py @@ -0,0 +1,59 @@ +from rest_framework.response import Response +from rest_framework import status + +from marketplace.core.types.ecommerce.vtex.serializers import ( + VtexSerializer, + VtexAppSerializer, +) +from marketplace.core.types import views +from marketplace.services.vtex.generic_service import VtexService +from marketplace.services.vtex.generic_service import APICredentials + + +class VtexViewSet(views.BaseAppTypeViewSet): + serializer_class = VtexAppSerializer + service_class = VtexService + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._service = None + + @property + def service(self): # pragma: no cover + if not self._service: + self._service = self.service_class() + + return self._service + + def perform_create(self, serializer): + serializer.save(code=self.type_class.code) + + def create(self, request, *args, **kwargs): + serializer = VtexSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + # Validate before starting creation + credentials = APICredentials( + app_key=validated_data.get("app_key"), + app_token=validated_data.get("app_token"), + domain=validated_data.get("domain"), + ) + self.service.check_is_valid_credentials(credentials) + # Calls the create method of the base class to create the App object + super().create(request, *args, **kwargs) + app = self.get_app() + try: + updated_app = self.service.configure(app, credentials) + return Response( + data=self.get_serializer(updated_app).data, + status=status.HTTP_201_CREATED, + ) + + except Exception as e: + app.delete() # if there are exceptions, remove the created instance + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/marketplace/core/types/externals/vtex/views.py b/marketplace/core/types/externals/vtex/views.py deleted file mode 100644 index 66b75fc9..00000000 --- a/marketplace/core/types/externals/vtex/views.py +++ /dev/null @@ -1,48 +0,0 @@ -from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework import status - -from marketplace.core.types.externals.vtex.serializers import VtexDomainSerializer -from marketplace.core.types.externals.vtex.serializers import VtexSerializer -from marketplace.core.types import views -from marketplace.clients.vtex.client import VtexPublicClient -from marketplace.services.vtex.public.products.products_vtex_service import ( - VtexProductsService, -) - - -class VtexViewSet(views.BaseAppTypeViewSet): - serializer_class = VtexSerializer - - service_class = VtexProductsService - client_class = VtexPublicClient - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._service = None - - @property - def service(self): # pragma: no cover - if not self._service: - self._service = self.service_class(self.client_class()) - return self._service - - def perform_create(self, serializer): - serializer.save(code=self.type_class.code) - - @action(detail=True, methods=["PATCH"]) - def configure(self, request, *args, **kwargs): - app_instance = self.get_object() - serializer = VtexDomainSerializer(data=request.data) - if serializer.is_valid(raise_exception=True): - domain = serializer.validated_data["domain"] - try: - updated_app = self.service.configure(app_instance, domain) - return Response(data=self.get_serializer(updated_app).data) - except ValueError as e: - return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/marketplace/core/types/views.py b/marketplace/core/types/views.py index 605b6349..f0da52e9 100644 --- a/marketplace/core/types/views.py +++ b/marketplace/core/types/views.py @@ -24,6 +24,7 @@ class BaseAppTypeViewSet( queryset = App.objects lookup_field = "uuid" permission_classes = [ProjectManagePermission] + app = None def get_queryset(self): return super().get_queryset().filter(code=self.type_class.code) @@ -35,7 +36,14 @@ def create(self, request, *args, **kwargs): data = {"error": "Exceeded the integration limit for this App"} return Response(data, status=status.HTTP_403_FORBIDDEN) - return super().create(request, *args, **kwargs) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + self.set_app(serializer.instance) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) def perform_destroy(self, instance): super().perform_destroy(instance) @@ -44,3 +52,9 @@ def perform_destroy(self, instance): if channel_uuid: client = ConnectProjectClient() client.release_channel(channel_uuid, project_uuid, self.request.user.email) + + def set_app(self, instance: App): + self.app = instance + + def get_app(self) -> App: + return self.app diff --git a/marketplace/services/vtex/exceptions.py b/marketplace/services/vtex/exceptions.py new file mode 100644 index 00000000..6d662dd5 --- /dev/null +++ b/marketplace/services/vtex/exceptions.py @@ -0,0 +1,20 @@ +from rest_framework import status # TODO: Create status enumeration class +from rest_framework.exceptions import APIException + + +class NoVTEXAppConfiguredException(APIException): + status_code = status.HTTP_404_NOT_FOUND + default_detail = "There is no VTEX App configured." + default_code = "no_vtex_app_configured" + + +class MultipleVTEXAppsConfiguredException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Multiple VTEX Apps are configured, which is not expected." + default_code = "multiple_vtex_apps_configured" + + +class CredentialsValidationError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "The credentials provided are invalid." + default_code = "invalid_credentials" diff --git a/marketplace/services/vtex/generic_service.py b/marketplace/services/vtex/generic_service.py new file mode 100644 index 00000000..631516f7 --- /dev/null +++ b/marketplace/services/vtex/generic_service.py @@ -0,0 +1,111 @@ +""" +Service for managing VTEX App instances within a project. + +This service provides methods to retrieve a configured VTEX App instance by its project UUID, +validate API credentials, and configure a VTEX App instance with provided credentials. + +Attributes: + None + +Methods: + get_vtex_app_or_error(project_uuid): Retrieves a single configured VTEX App instance + associated with the provided project UUID or raises an exception if not found or if + multiple instances are found. + + check_is_valid_credentials(credentials): Validates the provided API credentials against + VTEX's services. Raises an exception if the credentials are invalid. + + configure(app, credentials): Configures a VTEX App instance with the provided API credentials. + Updates the app configuration and marks it as configured. + +Private Methods: + _update_config(app, key, data): Updates the configuration of the given App instance + with the provided data under the specified configuration key. + +Raises: + NoVTEXAppConfiguredException: If no VTEX App is configured for the given project UUID. + MultipleVTEXAppsConfiguredException: If multiple configured VTEX Apps are found for the + given project UUID, which is unexpected behavior. + CredentialsValidationError: If the provided API credentials are found to be invalid during + validation. + +Data Classes: + APICredentials: Data class that holds the structure for VTEX API credentials. + +Exceptions: + NoVTEXAppConfiguredException: Raised as an HTTP 404 Not Found if no VTEX App is configured + for the given project UUID. + MultipleVTEXAppsConfiguredException: Raised as an HTTP 400 Bad Request if multiple configured + VTEX Apps are found for the given project UUID, which is unexpected behavior. + CredentialsValidationError: Raised as an HTTP 400 Bad Request if provided API credentials + are invalid. + +""" +from dataclasses import dataclass +from django.core.exceptions import MultipleObjectsReturned + +from marketplace.applications.models import App +from marketplace.services.vtex.private.products.service import ( + PrivateProductsService, +) +from marketplace.clients.vtex.client import VtexPrivateClient +from marketplace.services.vtex.exceptions import ( + CredentialsValidationError, + NoVTEXAppConfiguredException, + MultipleVTEXAppsConfiguredException, +) + + +@dataclass +class APICredentials: + domain: str + app_key: str + app_token: str + + def to_dict(self): + return { + "domain": self.domain, + "app_key": self.app_key, + "app_token": self.app_token, + } + + +class VtexService: + def get_vtex_app_or_error(self, project_uuid): + try: + app_vtex = App.objects.get( + code="vtex", project_uuid=str(project_uuid), configured=True + ) + return app_vtex + except App.DoesNotExist: + raise NoVTEXAppConfiguredException() + except MultipleObjectsReturned: + raise MultipleVTEXAppsConfiguredException() + + def check_is_valid_credentials(self, credentials: APICredentials) -> bool: + pvt_service = PrivateProductsService( + VtexPrivateClient( + credentials.app_key, + credentials.app_token, + ) + ) + if not pvt_service.validate_private_credentials(credentials.domain): + raise CredentialsValidationError() + + return True + + def configure(self, app, credentials: APICredentials) -> App: + updated_app = self._update_config( + app, key="api_credentials", data=credentials.to_dict() + ) + updated_app.configured = True + updated_app.save() + return updated_app + + # ================================ + # Private Methods + # ================================ + + def _update_config(self, app, key, data): + app.config[key] = data + return app diff --git a/marketplace/services/vtex/private/__init__.py b/marketplace/services/vtex/private/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/private/products/__init__.py b/marketplace/services/vtex/private/products/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/private/products/service.py b/marketplace/services/vtex/private/products/service.py new file mode 100644 index 00000000..6a2cc3c3 --- /dev/null +++ b/marketplace/services/vtex/private/products/service.py @@ -0,0 +1,62 @@ +""" +Service for interacting with VTEX private APIs that require authentication. + +This service is responsible for validating domain and credentials against VTEX private APIs. +It encapsulates the logic for domain validation and credentials checking, ensuring that only +valid and authenticated requests are processed for private VTEX operations. + +Attributes: + client: A configured client instance that is capable of communicating with VTEX private APIs. + +Public Methods: + check_is_valid_domain(domain): Validates the provided domain to ensure it is recognized by VTEX. + Raises a CredentialsValidationError if the domain is not valid. + + validate_private_credentials(domain): Validates the credentials stored in the client for the given domain. + Returns True if the credentials are valid, False otherwise. + +Private Methods: + _is_domain_valid(domain): Performs a check against the VTEX API to determine if the provided domain is valid. + +Exceptions: + CredentialsValidationError: Raised when the provided domain + or credentials are not valid according to VTEX's standards. + +Usage: + To use this service, instantiate it with a client that has the necessary API credentials (app_key and app_token). + The client should implement methods for checking domain validity and credentials. + +Example: + client = VtexPrivateClient(app_key="your-app-key", app_token="your-app-token") + service = PrivateProductsService(client) + is_valid = service.validate_private_credentials("your-domain.vtex.com") + if is_valid: + # Proceed with operations that require valid credentials +""" +from marketplace.services.vtex.exceptions import CredentialsValidationError + + +class PrivateProductsService: + def __init__(self, client): + self.client = client + + # ================================ + # Public Methods + # ================================ + + def check_is_valid_domain(self, domain): + if not self._is_domain_valid(domain): + raise CredentialsValidationError() + + return True + + def validate_private_credentials(self, domain): + self.check_is_valid_domain(domain) + return self.client.is_valid_credentials(domain) + + # ================================ + # Private Methods + # ================================ + + def _is_domain_valid(self, domain): + return self.client.check_domain(domain) diff --git a/marketplace/services/vtex/public/products/products_vtex_service.py b/marketplace/services/vtex/public/products/products_vtex_service.py deleted file mode 100644 index 10df1d22..00000000 --- a/marketplace/services/vtex/public/products/products_vtex_service.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Service for interfacing with VTEX API. - -This service deals with communication with the public API of the VTEX platform, enabling interaction -with various features like product list and domain verification required to -integrating VTEX into the market. - -Attributes: - client (ClientType): An instance of a client responsible for making API requests to the VTEX platform. - -Methods: - is_domain_valid(domain): Validates the VTEX domain by checking its API response. - - list_all_products(domain): Lists all products from the VTEX store of the given domain. - - update_config(app, key, value): Updates the configuration for the given App by setting a value for a specific key. - - configure(app, domain): Configures the given App with the VTEX domain if valid and marks the app as configured. - -Raises: - ValueError: If the provided domain is invalid during the configuration process. -""" - - -class VtexProductsService: - def __init__(self, client): - self.client = client - - # ================================ - # Public Methods - # ================================ - - def is_domain_valid(self, domain): - return self.client.check_domain(domain) - - def list_all_products(self, domain): - self._check_is_valid_domain(domain) - return self.client.list_products(domain) - - def configure(self, app, domain): - self._check_is_valid_domain(domain) - updated_app = self._update_config(app, "domain", domain) - updated_app.configured = True - updated_app.save() - return updated_app - - # ================================ - # Private Methods - # ================================ - - def _update_config(self, app, key, value): - app.config[key] = value - return app - - def _check_is_valid_domain(self, domain): - if not self.is_domain_valid(domain): - raise ValueError("The domain provided is invalid.") diff --git a/marketplace/services/vtex/public/products/service.py b/marketplace/services/vtex/public/products/service.py new file mode 100644 index 00000000..800b96ea --- /dev/null +++ b/marketplace/services/vtex/public/products/service.py @@ -0,0 +1,61 @@ +""" +Service for interacting with the public VTEX API. + +This service provides methods for interacting with the public-facing features of the VTEX platform, +such as product listing and domain validation. It facilitates the integration of VTEX functionalities +into the marketplace without requiring private API credentials. + +Attributes: + client: An instance of a client equipped to make requests to the public VTEX API endpoints. + +Public Methods: + list_all_products(domain): Retrieves a list of all products from the VTEX store for the specified domain. + The domain is first validated to ensure it corresponds to an active VTEX store. + + check_is_valid_domain(domain): Validates the provided domain by performing a + check against VTEX public API endpoints. + Raises a ValidationError if the domain does not correspond to a valid VTEX store. + +Raises: + ValidationError: If the domain provided is not recognized by VTEX as a valid + store domain during the domain verification process. + +Usage: + This service is intended to be used where public information about a VTEX store is needed. + It does not require API keys or tokens + for authentication, as it interacts with endpoints that are publicly accessible. + +Example: + client = VtexPublicClient() + service = PublicProductsService(client) + if service.check_is_valid_domain("example.vtex.com"): + products = service.list_all_products("example.vtex.com") + # Process the list of products +""" +from marketplace.services.vtex.exceptions import CredentialsValidationError + + +class PublicProductsService: + def __init__(self, client): + self.client = client + + # ================================ + # Public Methods + # ================================ + + def list_all_products(self, domain): + self.check_is_valid_domain(domain) + return self.client.list_products(domain) + + def check_is_valid_domain(self, domain): + if not self._is_domain_valid(domain): + raise CredentialsValidationError() + + return True + + # ================================ + # Private Methods + # ================================ + + def _is_domain_valid(self, domain): + return self.client.check_domain(domain) diff --git a/marketplace/services/vtex/public/products/tests/test_products_vtex_service.py b/marketplace/services/vtex/public/products/tests/test_products_vtex_service.py index c456817e..0acb7923 100644 --- a/marketplace/services/vtex/public/products/tests/test_products_vtex_service.py +++ b/marketplace/services/vtex/public/products/tests/test_products_vtex_service.py @@ -4,10 +4,10 @@ from django.contrib.auth import get_user_model from marketplace.applications.models import App -from marketplace.services.vtex.public.products.products_vtex_service import ( - VtexProductsService, +from marketplace.services.vtex.public.products.service import ( + PublicProductsService, ) - +from marketplace.services.vtex.exceptions import CredentialsValidationError User = get_user_model() @@ -30,7 +30,7 @@ def check_domain(self, domain): class TestVtexPublicProducts(TestCase): def setUp(self): user, _bool = User.objects.get_or_create(email="user-fbaservice@marketplace.ai") - self.service = VtexProductsService(client=MockClient()) + self.service = PublicProductsService(client=MockClient()) self.app = App.objects.create( code="vtex", config={}, @@ -44,13 +44,9 @@ def setUp(self): # ================================ def test_is_domain_valid(self): - response = self.service.is_domain_valid("valid.domain.com") + response = self.service.check_is_valid_domain("valid.domain.com") self.assertTrue(response) - def test_configure_valid(self): - response = self.service.configure(self.app, "valid.domain.com") - self.assertEqual(response.config, {"domain": "valid.domain.com"}) - def test_list_all_products(self): response = self.service.list_all_products("valid.domain.com") self.assertEqual(len(response), 1) @@ -60,15 +56,15 @@ def test_list_all_products(self): # ================================ def test_list_all_products_invalid_domain(self): - with self.assertRaises(ValueError) as context: + with self.assertRaises(CredentialsValidationError) as context: self.service.list_all_products("invalid.domain.com") - self.assertTrue("The domain provided is invalid." in str(context.exception)) - - def test_configure_invalid_domain(self): - with self.assertRaises(ValueError) as context: - self.service.configure(self.app, "invalid.domain.com") - self.assertTrue("The domain provided is invalid." in str(context.exception)) + self.assertTrue( + "The credentials provided are invalid." in str(context.exception) + ) def test_is_domain_invalid_domain(self): - response = self.service.is_domain_valid("invalid.domain.com") - self.assertFalse(response) + with self.assertRaises(CredentialsValidationError) as context: + self.service.check_is_valid_domain("invalid.domain.com") + self.assertTrue( + "The credentials provided are invalid." in str(context.exception) + ) diff --git a/marketplace/settings.py b/marketplace/settings.py index d00dce81..de5e3deb 100644 --- a/marketplace/settings.py +++ b/marketplace/settings.py @@ -276,7 +276,7 @@ # Externals APPTYPE_OMIE_PATH = "externals.omie.type.OmieType" APPTYPE_CHATGPT_PATH = "externals.chatgpt.type.ChatGPTType" -APPTYPE_VTEX_PATH = "externals.vtex.type.VtexType" +APPTYPE_VTEX_PATH = "ecommerce.vtex.type.VtexType" APPTYPES_CLASSES = [ APPTYPE_WENI_WEB_CHAT_PATH, From 3f9e912127158a02048c8262140041d7b757600e Mon Sep 17 00:00:00 2001 From: elitonzky Date: Fri, 24 Nov 2023 16:52:39 -0300 Subject: [PATCH 05/16] Links wpp-cloud app to vtex app --- .../core/types/ecommerce/vtex/serializers.py | 18 ++- .../types/ecommerce/vtex/tests/test_views.py | 41 +++++- .../core/types/ecommerce/vtex/views.py | 3 +- marketplace/services/vtex/generic_service.py | 37 +++--- .../vtex/private/products/tests/__init__.py | 0 .../products/tests/test_private_services.py | 39 ++++++ marketplace/services/vtex/tests/__init__.py | 0 .../vtex/tests/test_generic_service.py | 119 ++++++++++++++++++ 8 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 marketplace/services/vtex/private/products/tests/__init__.py create mode 100644 marketplace/services/vtex/private/products/tests/test_private_services.py create mode 100644 marketplace/services/vtex/tests/__init__.py create mode 100644 marketplace/services/vtex/tests/test_generic_service.py diff --git a/marketplace/core/types/ecommerce/vtex/serializers.py b/marketplace/core/types/ecommerce/vtex/serializers.py index 4d08ceb5..e1354cbb 100644 --- a/marketplace/core/types/ecommerce/vtex/serializers.py +++ b/marketplace/core/types/ecommerce/vtex/serializers.py @@ -1,17 +1,27 @@ from rest_framework import serializers +from rest_framework.exceptions import ValidationError from marketplace.core.serializers import AppTypeBaseSerializer from marketplace.applications.models import App -class VtexDomainSerializer(serializers.Serializer): - domain = serializers.CharField(required=True) - - class VtexSerializer(serializers.Serializer): domain = serializers.CharField(required=True) app_key = serializers.CharField(required=True) app_token = serializers.CharField(required=True) + wpp_cloud_uuid = serializers.UUIDField(required=True) + + def validate_wpp_cloud_uuid(self, value): + """ + Check that the wpp_cloud_uuid corresponds to an existing App with code 'wpp-cloud'. + """ + try: + App.objects.get(uuid=value, code="wpp-cloud") + except App.DoesNotExist: + raise ValidationError( + "The wpp_cloud_uuid does not correspond to a valid 'wpp-cloud' App." + ) + return str(value) class VtexAppSerializer(AppTypeBaseSerializer): diff --git a/marketplace/core/types/ecommerce/vtex/tests/test_views.py b/marketplace/core/types/ecommerce/vtex/tests/test_views.py index 94a5291c..a9186218 100644 --- a/marketplace/core/types/ecommerce/vtex/tests/test_views.py +++ b/marketplace/core/types/ecommerce/vtex/tests/test_views.py @@ -27,9 +27,10 @@ def get_vtex_app_or_error(self, project_uuid): def check_is_valid_credentials(self, credentials): return True - def configure(self, app, credentials): - app.configured = True + def configure(self, app, credentials, wpp_cloud_uuid): app.config["api_credentials"] = credentials.to_dict() + app.config["wpp_cloud_uuid"] = wpp_cloud_uuid + app.configured = True app.save() return app @@ -57,15 +58,23 @@ class CreateVtexAppTestCase(SetUpService): def setUp(self): super().setUp() - project_uuid = str(uuid.uuid4()) + self.project_uuid = str(uuid.uuid4()) + + self.wpp_cloud = App.objects.create( + code="wpp-cloud", + created_by=self.user, + project_uuid=self.project_uuid, + platform=App.PLATFORM_WENI_FLOWS, + ) self.body = { - "project_uuid": project_uuid, + "project_uuid": self.project_uuid, "app_key": "valid-app-key", "app_token": "valid-app-token", "domain": "valid.domain.com", + "wpp_cloud_uuid": str(self.wpp_cloud.uuid), } self.user_authorization = self.user.authorizations.create( - project_uuid=project_uuid, role=ProjectAuthorization.ROLE_CONTRIBUTOR + project_uuid=self.project_uuid, role=ProjectAuthorization.ROLE_CONTRIBUTOR ) def test_request_ok(self): @@ -91,6 +100,28 @@ def test_create_app_without_permission(self): response = self.request.post(self.url, self.body) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_create_app_without_valid_wpp_cloud_app(self): + not_wpp_cloud = App.objects.create( + code="wpp", + created_by=self.user, + project_uuid=self.project_uuid, + platform=App.PLATFORM_WENI_FLOWS, + ) + body = { + "project_uuid": self.project_uuid, + "app_key": "valid-app-key", + "app_token": "valid-app-token", + "domain": "valid.domain.com", + "wpp_cloud_uuid": str(not_wpp_cloud.uuid), + } + response = self.request.post(self.url, body) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("wpp_cloud_uuid", response.data) + self.assertIn( + "does not correspond to a valid 'wpp-cloud' App", + str(response.data["wpp_cloud_uuid"]), + ) + def test_create_app_configuration_exception(self): original_configure = self.mock_service.configure self.mock_service.configure = Mock(side_effect=Exception("Configuration error")) diff --git a/marketplace/core/types/ecommerce/vtex/views.py b/marketplace/core/types/ecommerce/vtex/views.py index c831c1ec..bf20b79e 100644 --- a/marketplace/core/types/ecommerce/vtex/views.py +++ b/marketplace/core/types/ecommerce/vtex/views.py @@ -38,12 +38,13 @@ def create(self, request, *args, **kwargs): app_token=validated_data.get("app_token"), domain=validated_data.get("domain"), ) + wpp_cloud_uuid = validated_data["wpp_cloud_uuid"] self.service.check_is_valid_credentials(credentials) # Calls the create method of the base class to create the App object super().create(request, *args, **kwargs) app = self.get_app() try: - updated_app = self.service.configure(app, credentials) + updated_app = self.service.configure(app, credentials, wpp_cloud_uuid) return Response( data=self.get_serializer(updated_app).data, status=status.HTTP_201_CREATED, diff --git a/marketplace/services/vtex/generic_service.py b/marketplace/services/vtex/generic_service.py index 631516f7..804ede83 100644 --- a/marketplace/services/vtex/generic_service.py +++ b/marketplace/services/vtex/generic_service.py @@ -71,6 +71,17 @@ def to_dict(self): class VtexService: + def __init__(self, *args, **kwargs): + self._pvt_service = None + + def get_private_service( + self, app_key, app_token + ) -> PrivateProductsService: # pragma nocover + if not self._pvt_service: + client = VtexPrivateClient(app_key, app_token) + self._pvt_service = PrivateProductsService(client) + return self._pvt_service + def get_vtex_app_or_error(self, project_uuid): try: app_vtex = App.objects.get( @@ -83,29 +94,17 @@ def get_vtex_app_or_error(self, project_uuid): raise MultipleVTEXAppsConfiguredException() def check_is_valid_credentials(self, credentials: APICredentials) -> bool: - pvt_service = PrivateProductsService( - VtexPrivateClient( - credentials.app_key, - credentials.app_token, - ) + pvt_service = self.get_private_service( + credentials.app_key, credentials.app_token ) if not pvt_service.validate_private_credentials(credentials.domain): raise CredentialsValidationError() return True - def configure(self, app, credentials: APICredentials) -> App: - updated_app = self._update_config( - app, key="api_credentials", data=credentials.to_dict() - ) - updated_app.configured = True - updated_app.save() - return updated_app - - # ================================ - # Private Methods - # ================================ - - def _update_config(self, app, key, data): - app.config[key] = data + def configure(self, app, credentials: APICredentials, wpp_cloud_uuid) -> App: + app.config["api_credentials"] = credentials.to_dict() + app.config["wpp_cloud_uuid"] = wpp_cloud_uuid + app.configured = True + app.save() return app diff --git a/marketplace/services/vtex/private/products/tests/__init__.py b/marketplace/services/vtex/private/products/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/private/products/tests/test_private_services.py b/marketplace/services/vtex/private/products/tests/test_private_services.py new file mode 100644 index 00000000..93adcbc0 --- /dev/null +++ b/marketplace/services/vtex/private/products/tests/test_private_services.py @@ -0,0 +1,39 @@ +from django.test import TestCase + +from unittest.mock import Mock + +from marketplace.services.vtex.exceptions import CredentialsValidationError +from marketplace.services.vtex.private.products.service import PrivateProductsService + + +class MockClient: + def is_valid_credentials(self, domain): + return domain == "valid.domain.com" + + def check_domain(self, domain): + return domain in ["valid.domain.com", "another.valid.com"] + + +class PrivateProductsServiceTestCase(TestCase): + def setUp(self): + self.mock_client = MockClient() + self.service = PrivateProductsService(self.mock_client) + + def test_check_is_valid_domain_valid(self): + self.assertTrue(self.service.check_is_valid_domain("valid.domain.com")) + + def test_check_is_valid_domain_invalid(self): + with self.assertRaises(CredentialsValidationError): + self.service.check_is_valid_domain("invalid.domain.com") + + def test_validate_private_credentials_valid(self): + self.assertTrue(self.service.validate_private_credentials("valid.domain.com")) + + def test_validate_private_credentials_invalid_domain(self): + with self.assertRaises(CredentialsValidationError): + self.service.validate_private_credentials("invalid.domain.com") + + def test_validate_private_credentials_invalid_credentials(self): + self.mock_client.is_valid_credentials = Mock(return_value=False) + result = self.service.validate_private_credentials("valid.domain.com") + self.assertFalse(result) diff --git a/marketplace/services/vtex/tests/__init__.py b/marketplace/services/vtex/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/tests/test_generic_service.py b/marketplace/services/vtex/tests/test_generic_service.py new file mode 100644 index 00000000..10c508a6 --- /dev/null +++ b/marketplace/services/vtex/tests/test_generic_service.py @@ -0,0 +1,119 @@ +import uuid + +from django.test import TestCase + +from marketplace.applications.models import App +from marketplace.services.vtex.generic_service import VtexService, APICredentials +from marketplace.services.vtex.exceptions import ( + NoVTEXAppConfiguredException, + MultipleVTEXAppsConfiguredException, + CredentialsValidationError, +) +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class MockPrivateClient: + def __init__(self, app_key, app_token): + self.app_key = app_key + self.app_token = app_token + + def is_valid_credentials(self, domain): + return domain == "valid.domain.com" + + +class MockPrivateProductsService: + def __init__(self, client): + self.client = client + + def validate_private_credentials(self, domain): + return self.client.is_valid_credentials(domain) + + +class VtexServiceTestCase(TestCase): + def setUp(self): + self.mock_private_client = MockPrivateClient("fake_key", "fake_token") + self.mock_private_service = MockPrivateProductsService(self.mock_private_client) + + self.service = VtexService() + self.service._pvt_service = self.mock_private_service + self.project_uuid = uuid.uuid4() + self.user = User.objects.create_superuser(email="user@marketplace.ai") + # Vtex APp + self.app = App.objects.create( + code="vtex", + config={}, + created_by=self.user, + project_uuid=self.project_uuid, + platform=App.PLATFORM_VTEX, + configured=True, + ) + # Duplicated Apps + self.duplicate_project = uuid.uuid4() + self.duplicate_app_1 = App.objects.create( + code="vtex", + config={}, + created_by=self.user, + project_uuid=self.duplicate_project, + platform=App.PLATFORM_VTEX, + configured=True, + ) + self.duplicate_app_2 = App.objects.create( + code="vtex", + config={}, + created_by=self.user, + project_uuid=self.duplicate_project, + platform=App.PLATFORM_VTEX, + configured=True, + ) + # Wpp cloud App to Vtex App + self.wpp_cloud = App.objects.create( + code="wpp_cloud", + config={}, + created_by=self.user, + project_uuid=self.project_uuid, + platform=App.PLATFORM_VTEX, + configured=True, + ) + + def test_get_vtex_app_or_error_found(self): + response = self.service.get_vtex_app_or_error(self.project_uuid) + self.assertEqual(True, response.configured) + + def test_get_vtex_app_or_error_not_found(self): + project_uuid = uuid.uuid4() + + with self.assertRaises(NoVTEXAppConfiguredException): + self.service.get_vtex_app_or_error(project_uuid) + + def test_get_vtex_app_or_error_multiple_found(self): + with self.assertRaises(MultipleVTEXAppsConfiguredException): + self.service.get_vtex_app_or_error(self.duplicate_project) + + def test_check_is_valid_credentials_valid(self): + credentials = APICredentials( + domain="valid.domain.com", app_key="key", app_token="token" + ) + is_valid = self.service.check_is_valid_credentials(credentials) + self.assertTrue(is_valid) + + def test_check_is_valid_credentials_invalid(self): + credentials = APICredentials( + domain="invalid.domain.com", app_key="key", app_token="token" + ) + with self.assertRaises(CredentialsValidationError): + self.service.check_is_valid_credentials(credentials) + + def test_configure(self): + credentials = APICredentials( + domain="valid.domain.com", app_key="key", app_token="token" + ) + wpp_cloud_uuid = str(self.wpp_cloud.uuid) + configured_app = self.service.configure(self.app, credentials, wpp_cloud_uuid) + + self.assertTrue(configured_app.configured) + self.assertEqual( + configured_app.config["api_credentials"], credentials.to_dict() + ) + self.assertEqual(configured_app.config["wpp_cloud_uuid"], wpp_cloud_uuid) From b02b090f50cb15991e599ac0ca82739838eb410d Mon Sep 17 00:00:00 2001 From: elitonzky Date: Fri, 1 Dec 2023 14:48:29 -0300 Subject: [PATCH 06/16] Creates treatment class for Vtex API products and adds methos to private vtex service --- marketplace/clients/facebook/client.py | 7 +- marketplace/clients/vtex/client.py | 79 ++++++--- .../services/vtex/private/products/service.py | 77 ++++++--- marketplace/services/vtex/utils/__init__.py | 0 .../services/vtex/utils/data_processor.py | 159 ++++++++++++++++++ 5 files changed, 274 insertions(+), 48 deletions(-) create mode 100644 marketplace/services/vtex/utils/__init__.py create mode 100644 marketplace/services/vtex/utils/data_processor.py diff --git a/marketplace/clients/facebook/client.py b/marketplace/clients/facebook/client.py index 01215f57..40b59e64 100644 --- a/marketplace/clients/facebook/client.py +++ b/marketplace/clients/facebook/client.py @@ -54,7 +54,7 @@ def create_product_feed(self, product_catalog_id, name): return response.json() - def upload_product_feed(self, feed_id, file): + def upload_product_feed(self, feed_id, file, update_only=False): url = self.get_url + f"{feed_id}/uploads" headers = self._get_headers() @@ -65,7 +65,10 @@ def upload_product_feed(self, feed_id, file): file.content_type, ) } - response = self.make_request(url, method="POST", headers=headers, files=files) + params = {"update_only": update_only} + response = self.make_request( + url, method="POST", headers=headers, params=params, files=files + ) return response.json() def create_product_feed_via_url( diff --git a/marketplace/clients/vtex/client.py b/marketplace/clients/vtex/client.py index dad8e10e..c1bb926f 100644 --- a/marketplace/clients/vtex/client.py +++ b/marketplace/clients/vtex/client.py @@ -34,38 +34,67 @@ def search_product_by_sku_id(self, skuid, domain, sellerid=1): class VtexPrivateClient(VtexAuthorization, VtexCommonClient): - def get_products_sku_ids(self, domain): + def is_valid_credentials(self, domain): + try: + url = ( + f"https://{domain}/api/catalog_system/pvt/products/GetProductAndSkuIds" + ) + headers = self._get_headers() + response = self.make_request(url, method="GET", headers=headers) + return response.status_code == 200 + except Exception: + return False + + def list_all_products_sku_ids(self, domain, page_size=1000): all_skus = [] - page_size = 250 - _from = 1 - _to = page_size + page = 1 while True: - url = f"https://{domain}/api/catalog_system/pvt/products/GetProductAndSkuIds?_from={_from}&_to={_to}" + url = f"https://{domain}/api/catalog_system/pvt/sku/stockkeepingunitids?page={page}&pagesize={page_size}" headers = self._get_headers() response = self.make_request(url, method="GET", headers=headers) - data = response.json().get("data") - if data: - for sku_ids in data.values(): - all_skus.extend(sku_ids) - - total_products = response.json().get("range", {}).get("total", 0) - if _to >= total_products: - break - _from += page_size - _to += page_size - _to = min(_to, total_products) # To avoid overshooting the total count - else: + sku_ids = response.json() + if not sku_ids: break + all_skus.extend(sku_ids) + page += 1 + return all_skus - def is_valid_credentials(self, domain): - try: - url = f"https://{domain}/api/catalog_system/pvt/products/GetProductAndSkuIds" - headers = self._get_headers() - response = self.make_request(url, method="GET", headers=headers) - return response.status_code == 200 - except Exception: - return False + def list_active_sellers(self, domain): + url = f"https://{domain}/api/seller-register/pvt/sellers" + headers = self._get_headers() + response = self.make_request(url, method="GET", headers=headers) + sellers_data = response.json() + return [seller["id"] for seller in sellers_data["items"] if seller["isActive"]] + + def get_product_details(self, sku_id, domain): + url = ( + f"https://{domain}/api/catalog_system/pvt/sku/stockkeepingunitbyid/{sku_id}" + ) + headers = self._get_headers() + response = self.make_request(url, method="GET", headers=headers) + return response.json() + + def pub_simulate_cart_for_seller(self, sku_id, seller_id, domain): + cart_simulation_url = f"https://{domain}/api/checkout/pub/orderForms/simulation" + payload = {"items": [{"id": sku_id, "quantity": 1, "seller": seller_id}]} + + response = self.make_request(cart_simulation_url, method="POST", json=payload) + simulation_data = response.json() + + if simulation_data["items"]: + item_data = simulation_data["items"][0] + return { + "is_available": item_data["availability"] == "available", + "price": item_data["price"], + "list_price": item_data["listPrice"], + } + else: + return { + "is_available": False, + "price": 0, + "list_price": 0, + } diff --git a/marketplace/services/vtex/private/products/service.py b/marketplace/services/vtex/private/products/service.py index 6a2cc3c3..d71574b9 100644 --- a/marketplace/services/vtex/private/products/service.py +++ b/marketplace/services/vtex/private/products/service.py @@ -1,44 +1,45 @@ """ -Service for interacting with VTEX private APIs that require authentication. +Service for managing product operations with VTEX private APIs. -This service is responsible for validating domain and credentials against VTEX private APIs. -It encapsulates the logic for domain validation and credentials checking, ensuring that only -valid and authenticated requests are processed for private VTEX operations. +This service interacts with VTEX's private APIs for product-related operations. It handles +domain validation, credentials verification, product listing, and updates from webhook notifications. Attributes: - client: A configured client instance that is capable of communicating with VTEX private APIs. + client: A client instance for VTEX private APIs communication. + data_processor: DataProcessor instance for processing product data. Public Methods: - check_is_valid_domain(domain): Validates the provided domain to ensure it is recognized by VTEX. - Raises a CredentialsValidationError if the domain is not valid. - - validate_private_credentials(domain): Validates the credentials stored in the client for the given domain. - Returns True if the credentials are valid, False otherwise. - -Private Methods: - _is_domain_valid(domain): Performs a check against the VTEX API to determine if the provided domain is valid. + check_is_valid_domain(domain): Validates if a domain is recognized by VTEX. + validate_private_credentials(domain): Checks if stored credentials for a domain are valid. + list_all_products(domain): Lists all products from a domain. Returns processed product data. + get_product_details(sku_id, domain): Retrieves details for a specific SKU. + simulate_cart_for_seller(sku_id, seller_id, domain): Simulates a cart for a seller and SKU. + update_product_info(domain, webhook_payload): Updates product info based on webhook payload. Exceptions: - CredentialsValidationError: Raised when the provided domain - or credentials are not valid according to VTEX's standards. + CredentialsValidationError: Raised for invalid domain or credentials. Usage: - To use this service, instantiate it with a client that has the necessary API credentials (app_key and app_token). - The client should implement methods for checking domain validity and credentials. + Instantiate with a client having API credentials. Use methods for product operations with VTEX. Example: - client = VtexPrivateClient(app_key="your-app-key", app_token="your-app-token") + client = VtexPrivateClient(app_key="key", app_token="token") service = PrivateProductsService(client) - is_valid = service.validate_private_credentials("your-domain.vtex.com") + is_valid = service.validate_private_credentials("domain.vtex.com") if is_valid: - # Proceed with operations that require valid credentials + products = service.list_all_products("domain.vtex.com") + # Use products data as needed """ from marketplace.services.vtex.exceptions import CredentialsValidationError +from marketplace.services.vtex.utils.data_processor import DataProcessor class PrivateProductsService: - def __init__(self, client): + def __init__(self, client, data_processor=DataProcessor): self.client = client + self.data_processor = data_processor + # TODO: Check if it makes sense to leave the domain instantiated + # so that the domain parameter is removed from the methods # ================================ # Public Methods @@ -54,6 +55,40 @@ def validate_private_credentials(self, domain): self.check_is_valid_domain(domain) return self.client.is_valid_credentials(domain) + def list_all_products(self, domain): + active_sellers = self.client.list_active_sellers(domain) + skus_ids = self.client.list_all_products_sku_ids(domain) + + data = self.data_processor.process_product_data( + skus_ids, active_sellers, self, domain + ) + return data + + def get_product_details(self, sku_id, domain): + return self.client.get_product_details(sku_id, domain) + + def simulate_cart_for_seller(self, sku_id, seller_id, domain): + return self.client.pub_simulate_cart_for_seller( + sku_id, seller_id, domain + ) # TODO: Change to pvt_simulate_cart_for_seller + + def update_product_info(self, domain, webhook_payload): + updated_products = [] + + sku_id = webhook_payload["IdSku"] + price_modified = webhook_payload["PriceModified"] + stock_modified = webhook_payload["StockModified"] + other_changes = webhook_payload["HasStockKeepingUnitModified"] + + seller_ids = self.client.list_active_sellers(domain) + + if price_modified or stock_modified or other_changes: + updated_products = self.data_processor.process_product_data( + [sku_id], seller_ids, self, domain, update_product=True + ) + + return updated_products + # ================================ # Private Methods # ================================ diff --git a/marketplace/services/vtex/utils/__init__.py b/marketplace/services/vtex/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/utils/data_processor.py b/marketplace/services/vtex/utils/data_processor.py new file mode 100644 index 00000000..c03d39d6 --- /dev/null +++ b/marketplace/services/vtex/utils/data_processor.py @@ -0,0 +1,159 @@ +import csv +import io +import dataclasses + +from dataclasses import dataclass + +from typing import List + + +@dataclass +class FacebookProductDTO: + id: str + title: str + description: str + availability: str + condition: str + price: str + link: str + image_link: str + brand: str + sale_price: str + product_details: dict # TODO: Implement ProductDetailsDTO + + def get_multiplier(self): + return self.product_details.get("UnitMultiplier", 1.0) + + def get_weight(self): + return self.product_details["Dimension"]["weight"] + + def calculates_by_weight(self): + return self.product_details["MeasurementUnit"] != "un" + + +@dataclass +class VtexProductDTO: # TODO: Implement This VtexProductDTO + pass + + +class DataProcessor: + SEPARATOR = "#" + CURRENCY = "BRL" + + @staticmethod + def create_unique_product_id(sku_id, seller_id): + return f"{sku_id}{DataProcessor.SEPARATOR}{seller_id}" + + @staticmethod + def decode_unique_product_id(unique_product_id): + sku_id, seller_id = unique_product_id.split(DataProcessor.SEPARATOR) + return sku_id, seller_id + + @staticmethod + def extract_fields(product_details, availability_details) -> FacebookProductDTO: + price = ( + availability_details["price"] + if availability_details["price"] is not None + else 0 + ) + list_price = ( + availability_details["list_price"] + if availability_details["list_price"] is not None + else 0 + ) + return FacebookProductDTO( + id=product_details["Id"], + title=product_details["SkuName"], + description=product_details["SkuName"], + availability="in stock" + if availability_details["is_available"] + else "out of stock", + condition="new", + price=list_price, + link="", # TODO: Needs Implement This based on FacebookAPI + image_link=product_details["ImageUrl"], + brand=product_details.get("BrandName", "N/A"), + sale_price=price, + product_details=product_details, + ) + + @staticmethod + def format_price(price): + """Formats the price to the standard 'XX.XX BRL'.""" + formatted_price = f"{price / 100:.2f} {DataProcessor.CURRENCY}" # TODO: Move CURRENCY to business layer + return formatted_price + + @staticmethod + def format_fields( + seller_id, product: FacebookProductDTO + ): # TODO: Move this method rules to business layer + if product.calculates_by_weight(): + # Apply price calculation logic per weight/unit + DataProcessor.calculate_price_by_weight(product) + + # Format the price for all products + product.price = DataProcessor.format_price(product.price) + product.sale_price = DataProcessor.format_price(product.sale_price) + product.id = DataProcessor.create_unique_product_id(product.id, seller_id) + + return product + + @staticmethod + def calculate_price_by_weight(product: FacebookProductDTO): + unit_multiplier = product.get_multiplier() + product_weight = product.get_weight() + weight = product_weight * unit_multiplier + product.price = product.price * unit_multiplier + product.sale_price = product.sale_price * unit_multiplier + product.description += f" - {weight}g" + + @staticmethod + def process_product_data( + skus_ids, active_sellers, service, domain, update_product=False + ): + facebook_products = [] + for sku_id in skus_ids: + product_details = service.get_product_details(sku_id, domain) + + for seller_id in active_sellers: + availability_details = service.simulate_cart_for_seller( + sku_id, seller_id, domain + ) + + if update_product is False: + if not availability_details["is_available"]: + continue + + extracted_product_dto = DataProcessor.extract_fields( + product_details, availability_details + ) + formatted_product_dto = DataProcessor.format_fields( + seller_id, extracted_product_dto + ) + facebook_products.append(formatted_product_dto) + + return facebook_products + + def products_to_csv(products: List[FacebookProductDTO]) -> str: + output = io.StringIO() + fieldnames = [ + field.name + for field in dataclasses.fields(FacebookProductDTO) + if field.name != "product_details" + ] + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + for product in products: + row = dataclasses.asdict(product) + row.pop( + "product_details", None + ) # TODO: should change this logic before going to production + writer.writerow(row) + + return output.getvalue() + + @staticmethod + def generate_csv_file(csv_content: str) -> io.BytesIO: + csv_bytes = csv_content.encode("utf-8") + csv_memory = io.BytesIO(csv_bytes) + return csv_memory From 272ac7410cf381b4c98c435a1282512d196d8670 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Fri, 1 Dec 2023 17:14:27 -0300 Subject: [PATCH 07/16] Hiding tokens and adds initial_sync_completed key into config --- marketplace/core/types/ecommerce/vtex/serializers.py | 11 +++++++++++ marketplace/services/vtex/generic_service.py | 1 + 2 files changed, 12 insertions(+) diff --git a/marketplace/core/types/ecommerce/vtex/serializers.py b/marketplace/core/types/ecommerce/vtex/serializers.py index e1354cbb..f31d6cdd 100644 --- a/marketplace/core/types/ecommerce/vtex/serializers.py +++ b/marketplace/core/types/ecommerce/vtex/serializers.py @@ -25,6 +25,8 @@ def validate_wpp_cloud_uuid(self, value): class VtexAppSerializer(AppTypeBaseSerializer): + config = serializers.SerializerMethodField() + class Meta: model = App fields = ( @@ -38,3 +40,12 @@ class Meta: "modified_by", ) read_only_fields = ("code", "uuid", "platform") + + def get_config(self, obj): + config = obj.config.copy() + api_credentials = config.get("api_credentials", {}) + if api_credentials: + api_credentials["app_key"] = "***" + api_credentials["app_token"] = "***" + + return config diff --git a/marketplace/services/vtex/generic_service.py b/marketplace/services/vtex/generic_service.py index 804ede83..9bc9c5d9 100644 --- a/marketplace/services/vtex/generic_service.py +++ b/marketplace/services/vtex/generic_service.py @@ -105,6 +105,7 @@ def check_is_valid_credentials(self, credentials: APICredentials) -> bool: def configure(self, app, credentials: APICredentials, wpp_cloud_uuid) -> App: app.config["api_credentials"] = credentials.to_dict() app.config["wpp_cloud_uuid"] = wpp_cloud_uuid + app.config["initial_sync_completed"] = False app.configured = True app.save() return app From eeb50f4f3f012eab1af1e27eedd608382b9710e1 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Mon, 4 Dec 2023 10:03:04 -0300 Subject: [PATCH 08/16] Notifies of flows when creating app vtex --- marketplace/clients/flows/client.py | 12 ++++++++++++ marketplace/core/types/ecommerce/vtex/views.py | 15 +++++++++++++++ marketplace/services/flows/service.py | 6 ++++++ 3 files changed, 33 insertions(+) create mode 100644 marketplace/services/flows/service.py diff --git a/marketplace/clients/flows/client.py b/marketplace/clients/flows/client.py index 67c28ab1..0d2df017 100644 --- a/marketplace/clients/flows/client.py +++ b/marketplace/clients/flows/client.py @@ -52,3 +52,15 @@ def update_config(self, data, flow_object_uuid): json=payload, ) return response + + def notify_vtex_app_creation(self, project_uuid, user_email): + url = f"{self.base_url}/internals/orgs/{project_uuid}/update-vtex/" + payload = {"user_email": user_email} + + self.make_request( + url, + method="POST", + headers=self.authentication_instance.headers, + data=payload, + ) + return True diff --git a/marketplace/core/types/ecommerce/vtex/views.py b/marketplace/core/types/ecommerce/vtex/views.py index bf20b79e..8dd6fcb5 100644 --- a/marketplace/core/types/ecommerce/vtex/views.py +++ b/marketplace/core/types/ecommerce/vtex/views.py @@ -8,15 +8,20 @@ from marketplace.core.types import views from marketplace.services.vtex.generic_service import VtexService from marketplace.services.vtex.generic_service import APICredentials +from marketplace.services.flows.service import FlowsService +from marketplace.clients.flows.client import FlowsClient class VtexViewSet(views.BaseAppTypeViewSet): serializer_class = VtexAppSerializer service_class = VtexService + flows_service_class = FlowsService + flows_client = FlowsClient def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._service = None + self._flows_service = None @property def service(self): # pragma: no cover @@ -25,6 +30,13 @@ def service(self): # pragma: no cover return self._service + @property + def flows_service(self): # pragma: no cover + if not self._flows_service: + self._flows_service = self.flows_service_class(self.flows_client()) + + return self._flows_service + def perform_create(self, serializer): serializer.save(code=self.type_class.code) @@ -45,6 +57,9 @@ def create(self, request, *args, **kwargs): app = self.get_app() try: updated_app = self.service.configure(app, credentials, wpp_cloud_uuid) + # self.flows_service.notify_vtex_app_creation( + # app.project_uuid, app.created_by.email + # ) return Response( data=self.get_serializer(updated_app).data, status=status.HTTP_201_CREATED, diff --git a/marketplace/services/flows/service.py b/marketplace/services/flows/service.py new file mode 100644 index 00000000..98adc226 --- /dev/null +++ b/marketplace/services/flows/service.py @@ -0,0 +1,6 @@ +class FlowsService: + def __init__(self, client): + self.client = client + + def notify_vtex_app_creation(self, project_uuid, user_email): + return self.client.notify_vtex_app_creation(project_uuid, user_email) From 229fcfa725f32928f02399834448cc144f19cddf Mon Sep 17 00:00:00 2001 From: elitonzky Date: Tue, 5 Dec 2023 14:36:54 -0300 Subject: [PATCH 09/16] Notifies the project in flows when there are changes in the Vtex App linked to the project --- marketplace/clients/flows/client.py | 13 ++++------- .../types/ecommerce/vtex/tests/test_views.py | 23 +++++++++++++++++-- .../core/types/ecommerce/vtex/views.py | 9 +++++--- marketplace/services/flows/service.py | 6 +++-- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/marketplace/clients/flows/client.py b/marketplace/clients/flows/client.py index 0d2df017..a14a44db 100644 --- a/marketplace/clients/flows/client.py +++ b/marketplace/clients/flows/client.py @@ -1,6 +1,4 @@ """Client for connection with flows""" - - from django.conf import settings from marketplace.clients.base import RequestClient @@ -53,14 +51,13 @@ def update_config(self, data, flow_object_uuid): ) return response - def notify_vtex_app_creation(self, project_uuid, user_email): - url = f"{self.base_url}/internals/orgs/{project_uuid}/update-vtex/" + def update_vtex_integration_status(self, project_uuid, user_email, action): + url = f"{self.base_url}/api/v2/internals/orgs/{project_uuid}/update-vtex/" payload = {"user_email": user_email} - self.make_request( - url, - method="POST", + url=url, + method=action, headers=self.authentication_instance.headers, - data=payload, + json=payload, ) return True diff --git a/marketplace/core/types/ecommerce/vtex/tests/test_views.py b/marketplace/core/types/ecommerce/vtex/tests/test_views.py index a9186218..951724f1 100644 --- a/marketplace/core/types/ecommerce/vtex/tests/test_views.py +++ b/marketplace/core/types/ecommerce/vtex/tests/test_views.py @@ -13,6 +13,7 @@ from marketplace.applications.models import App from marketplace.core.types.ecommerce.vtex.views import VtexViewSet from marketplace.core.types.ecommerce.vtex.type import VtexType +from marketplace.clients.flows.client import FlowsClient apptype = VtexType() @@ -35,13 +36,18 @@ def configure(self, app, credentials, wpp_cloud_uuid): return app +class MockFlowsService: + def update_vtex_integration_status(self, project_uuid, user_email, action): + return True + + class SetUpService(APIBaseTestCase): view_class = VtexViewSet def setUp(self): super().setUp() - # Mock service + # Mock vtex service self.mock_service = MockVtexService() patcher = patch.object( self.view_class, @@ -52,6 +58,19 @@ def setUp(self): self.addCleanup(patcher.stop) patcher.start() + # Mock FlowsClient + self.mock_flows_client = Mock(spec=FlowsClient) + self.mock_flows_service = MockFlowsService() + self.mock_flows_service.flows_client = self.mock_flows_client + + patcher_flows = patch.object( + self.view_class, + "flows_service", + PropertyMock(return_value=self.mock_flows_service), + ) + self.addCleanup(patcher_flows.stop) + patcher_flows.start() + class CreateVtexAppTestCase(SetUpService): url = reverse("vtex-app-list") @@ -166,7 +185,7 @@ def test_retrieve_app_data(self): self.assertEqual(response.json["config"], {}) -class DeleteVtexAppTestCase(APIBaseTestCase): +class DeleteVtexAppTestCase(SetUpService): view_class = VtexViewSet @property diff --git a/marketplace/core/types/ecommerce/vtex/views.py b/marketplace/core/types/ecommerce/vtex/views.py index 8dd6fcb5..e87a6911 100644 --- a/marketplace/core/types/ecommerce/vtex/views.py +++ b/marketplace/core/types/ecommerce/vtex/views.py @@ -57,9 +57,9 @@ def create(self, request, *args, **kwargs): app = self.get_app() try: updated_app = self.service.configure(app, credentials, wpp_cloud_uuid) - # self.flows_service.notify_vtex_app_creation( - # app.project_uuid, app.created_by.email - # ) + self.flows_service.update_vtex_integration_status( + app.project_uuid, app.created_by.email, action="POST" + ) return Response( data=self.get_serializer(updated_app).data, status=status.HTTP_201_CREATED, @@ -72,4 +72,7 @@ def create(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): instance = self.get_object() self.perform_destroy(instance) + self.flows_service.update_vtex_integration_status( + instance.project_uuid, instance.created_by.email, action="DELETE" + ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/marketplace/services/flows/service.py b/marketplace/services/flows/service.py index 98adc226..988e928f 100644 --- a/marketplace/services/flows/service.py +++ b/marketplace/services/flows/service.py @@ -2,5 +2,7 @@ class FlowsService: def __init__(self, client): self.client = client - def notify_vtex_app_creation(self, project_uuid, user_email): - return self.client.notify_vtex_app_creation(project_uuid, user_email) + def update_vtex_integration_status(self, project_uuid, user_email, action): + return self.client.update_vtex_integration_status( + project_uuid, user_email, action + ) From b843537b837b770366739b183031f78ee7c2f456 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Wed, 6 Dec 2023 17:17:03 -0300 Subject: [PATCH 10/16] Creates webhook to update vtex products --- marketplace/services/flows/service.py | 3 + .../services/vtex/private/products/service.py | 4 +- .../services/vtex/utils/data_processor.py | 3 + marketplace/settings.py | 1 + marketplace/urls.py | 7 ++- marketplace/webhooks/__init__.py | 0 marketplace/webhooks/urls.py | 5 ++ marketplace/webhooks/vtex/__init__.py | 0 marketplace/webhooks/vtex/product_updates.py | 60 +++++++++++++++++++ marketplace/webhooks/vtex/urls.py | 10 ++++ 10 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 marketplace/webhooks/__init__.py create mode 100644 marketplace/webhooks/urls.py create mode 100644 marketplace/webhooks/vtex/__init__.py create mode 100644 marketplace/webhooks/vtex/product_updates.py create mode 100644 marketplace/webhooks/vtex/urls.py diff --git a/marketplace/services/flows/service.py b/marketplace/services/flows/service.py index 988e928f..ab910d4c 100644 --- a/marketplace/services/flows/service.py +++ b/marketplace/services/flows/service.py @@ -6,3 +6,6 @@ def update_vtex_integration_status(self, project_uuid, user_email, action): return self.client.update_vtex_integration_status( project_uuid, user_email, action ) + + def update_vtex_products(self, products: list): + pass diff --git a/marketplace/services/vtex/private/products/service.py b/marketplace/services/vtex/private/products/service.py index d71574b9..712210c2 100644 --- a/marketplace/services/vtex/private/products/service.py +++ b/marketplace/services/vtex/private/products/service.py @@ -83,10 +83,12 @@ def update_product_info(self, domain, webhook_payload): seller_ids = self.client.list_active_sellers(domain) if price_modified or stock_modified or other_changes: - updated_products = self.data_processor.process_product_data( + updated_products_dto = self.data_processor.process_product_data( [sku_id], seller_ids, self, domain, update_product=True ) + updated_products = DataProcessor.convert_dtos_to_dicts(updated_products_dto) + return updated_products # ================================ diff --git a/marketplace/services/vtex/utils/data_processor.py b/marketplace/services/vtex/utils/data_processor.py index c03d39d6..c38f73b6 100644 --- a/marketplace/services/vtex/utils/data_processor.py +++ b/marketplace/services/vtex/utils/data_processor.py @@ -157,3 +157,6 @@ def generate_csv_file(csv_content: str) -> io.BytesIO: csv_bytes = csv_content.encode("utf-8") csv_memory = io.BytesIO(csv_bytes) return csv_memory + + def convert_dtos_to_dicts(dtos: List[FacebookProductDTO]) -> List[dict]: + return [dataclasses.asdict(dto) for dto in dtos] diff --git a/marketplace/settings.py b/marketplace/settings.py index de5e3deb..c2512fe1 100644 --- a/marketplace/settings.py +++ b/marketplace/settings.py @@ -66,6 +66,7 @@ "marketplace.wpp_templates", "marketplace.event_driven", "marketplace.wpp_products", + "marketplace.webhooks", # installed apps "rest_framework", "storages", diff --git a/marketplace/urls.py b/marketplace/urls.py index b123ac29..7dd68b95 100644 --- a/marketplace/urls.py +++ b/marketplace/urls.py @@ -11,12 +11,17 @@ from marketplace.swagger import view as swagger_view from marketplace.applications import urls as applications_urls from marketplace.interactions import urls as interactions_urls +from marketplace.webhooks import urls as webhooks_urls admin.site.unregister(Group) -api_urls = [path("", include(applications_urls)), path("", include(interactions_urls))] +api_urls = [ + path("", include(applications_urls)), + path("", include(interactions_urls)), + path("", include(webhooks_urls)), +] urlpatterns = [ diff --git a/marketplace/webhooks/__init__.py b/marketplace/webhooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/webhooks/urls.py b/marketplace/webhooks/urls.py new file mode 100644 index 00000000..b8606c01 --- /dev/null +++ b/marketplace/webhooks/urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path("webhook/", include("marketplace.webhooks.vtex.urls")), +] diff --git a/marketplace/webhooks/vtex/__init__.py b/marketplace/webhooks/vtex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/webhooks/vtex/product_updates.py b/marketplace/webhooks/vtex/product_updates.py new file mode 100644 index 00000000..9e6e91cc --- /dev/null +++ b/marketplace/webhooks/vtex/product_updates.py @@ -0,0 +1,60 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from marketplace.services.vtex.private.products.service import PrivateProductsService +from marketplace.clients.vtex.client import VtexPrivateClient +from marketplace.applications.models import App +from marketplace.services.vtex.exceptions import ( + CredentialsValidationError, + NoVTEXAppConfiguredException, +) +from marketplace.clients.flows.client import FlowsClient +from marketplace.services.flows.service import FlowsService + + +class VtexProductUpdateWebhook(APIView): + flows_client_class = FlowsClient + flows_service_class = FlowsService + vtex_client_class = VtexPrivateClient + vtex_service_class = PrivateProductsService + + def post(self, request, app_uuid): + app = self.get_app(app_uuid) + if not self.can_synchronize(app): + return Response( + {"error": "initial sync not completed"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + domain, app_key, app_token = self.get_credentials_or_raise(app) + vtex_service = self.get_vtex_service(app_key, app_token) + + products_updated = vtex_service.update_product_info(domain, request.data) + self.send_products_to_flows(products_updated) + return Response(status=status.HTTP_200_OK) + + def get_app(self, app_uuid): + try: + return App.objects.get(uuid=app_uuid, configured=True, code="vtex") + except App.DoesNotExist: + raise NoVTEXAppConfiguredException() + + def can_synchronize(self, app): + return app.config.get("initial_sync_completed", False) + + def get_credentials_or_raise(self, app): + domain = app.config["api_credentials"]["domain"] + app_key = app.config["api_credentials"]["app_key"] + app_token = app.config["api_credentials"]["app_token"] + if not domain or not app_key or not app_token: + raise CredentialsValidationError() + return domain, app_key, app_token + + def get_vtex_service(self, app_key, app_token): + client = self.vtex_client_class(app_key, app_token) + return self.vtex_service_class(client) + + def send_products_to_flows(self, products): + flows_service = self.flows_service_class(self.flows_client_class()) + flows_service.update_vtex_products(products) diff --git a/marketplace/webhooks/vtex/urls.py b/marketplace/webhooks/vtex/urls.py new file mode 100644 index 00000000..2980240f --- /dev/null +++ b/marketplace/webhooks/vtex/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .product_updates import VtexProductUpdateWebhook + +urlpatterns = [ + path( + "vtex//products-update/api/notification/", + VtexProductUpdateWebhook.as_view(), + name="vtex-product-updates", + ), +] From c5c57f81a9ac8bf842dffddcd4ee4a60117e5f91 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Tue, 12 Dec 2023 12:27:29 -0300 Subject: [PATCH 11/16] Adds tests to vtex webhook --- marketplace/webhooks/vtex/product_updates.py | 4 + marketplace/webhooks/vtex/tests/__init__.py | 0 .../vtex/tests/test_product_updates.py | 132 ++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 marketplace/webhooks/vtex/tests/__init__.py create mode 100644 marketplace/webhooks/vtex/tests/test_product_updates.py diff --git a/marketplace/webhooks/vtex/product_updates.py b/marketplace/webhooks/vtex/product_updates.py index 9e6e91cc..30d9d749 100644 --- a/marketplace/webhooks/vtex/product_updates.py +++ b/marketplace/webhooks/vtex/product_updates.py @@ -1,6 +1,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status +from rest_framework.permissions import AllowAny from marketplace.services.vtex.private.products.service import PrivateProductsService from marketplace.clients.vtex.client import VtexPrivateClient @@ -19,6 +20,9 @@ class VtexProductUpdateWebhook(APIView): vtex_client_class = VtexPrivateClient vtex_service_class = PrivateProductsService + authentication_classes = [] + permission_classes = [AllowAny] + def post(self, request, app_uuid): app = self.get_app(app_uuid) if not self.can_synchronize(app): diff --git a/marketplace/webhooks/vtex/tests/__init__.py b/marketplace/webhooks/vtex/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/webhooks/vtex/tests/test_product_updates.py b/marketplace/webhooks/vtex/tests/test_product_updates.py new file mode 100644 index 00000000..7482ed25 --- /dev/null +++ b/marketplace/webhooks/vtex/tests/test_product_updates.py @@ -0,0 +1,132 @@ +import uuid + +from unittest.mock import patch + +from django.urls import reverse + +from marketplace.core.tests.base import APIBaseTestCase +from marketplace.webhooks.vtex.product_updates import VtexProductUpdateWebhook +from marketplace.applications.models import App + + +class MockVtexService: + def update_product_info(self, domain, webhook_payload): + return [{"id": 1, "sku": 1}, {"id": 2, "sku": 2}] + + +class MockFlowsService: + def update_vtex_products(self, products): + return None + + +class SetUpTestBase(APIBaseTestCase): + view_class = VtexProductUpdateWebhook + + def setUp(self): + super().setUp() + api_credentials = { + "domain": "valid_domain", + "app_key": "valid_key", + "app_token": "valid_token", + } + config = { + "api_credentials": api_credentials, + "initial_sync_completed": True, + } + self.app = App.objects.create( + code="vtex", + created_by=self.user, + project_uuid=uuid.uuid4(), + platform=App.PLATFORM_VTEX, + configured=True, + config=config, + ) + self.url = reverse("vtex-product-updates", kwargs={"app_uuid": self.app.uuid}) + + self.body = { + "IdSku": "15", + "An": "prezunic.myvtex", + "IdAffiliate": "SPT", + "DateModified": "2023-08-20T15:11:28.1978783Z", + "IsActive": False, + "StockModified": False, + "PriceModified": False, + "HasStockKeepingUnitModified": True, + "HasStockKeepingUnitRemovedFromAffiliate": False, + } + + @property + def view(self): + return self.view_class.as_view() + + +class MockServiceTestCase(SetUpTestBase): + def setUp(self): + super().setUp() + + # Mock Vtex service + self.mock_vtex_service = MockVtexService() + patcher_vtex = patch.object( + self.view_class, + "vtex_service_class", + lambda *args, **kwargs: self.mock_vtex_service, + ) + self.addCleanup(patcher_vtex.stop) + patcher_vtex.start() + + # Mock Flows service + self.mock_flows_service = MockFlowsService() + patcher_flows = patch.object( + self.view_class, + "flows_service_class", + lambda *args, **kwargs: self.mock_flows_service, + ) + self.addCleanup(patcher_flows.stop) + patcher_flows.start() + + +class WebhookTestCase(MockServiceTestCase): + def test_request_ok(self): + response = self.request.post(self.url, self.body, app_uuid=self.app.uuid) + print(response.data) + self.assertEqual(response.status_code, 200) + + def test_webhook_with_valid_configuration(self): + self.app.config["initial_sync_completed"] = True + self.app.save() + + response = self.request.post( + self.url, {"data": "webhook_payload"}, app_uuid=self.app.uuid + ) + self.assertEqual(response.status_code, 200) + + def test_webhook_without_initial_sync(self): + self.app.config["initial_sync_completed"] = False + self.app.save() + + response = self.request.post( + self.url, {"data": "webhook_payload"}, app_uuid=self.app.uuid + ) + self.assertEqual(response.status_code, 400) + + def test_webhook_with_app_not_found(self): + app_uuid = uuid.uuid4() + url = reverse("vtex-product-updates", kwargs={"app_uuid": uuid.uuid4()}) + response = self.request.post( + url, {"data": "webhook_payload"}, app_uuid=app_uuid + ) + self.assertEqual(response.status_code, 404) + + def test_webhook_with_invalid_credentials(self): + self.app.config["initial_sync_completed"] = True + self.app.config["api_credentials"] = { + "domain": "", + "app_key": "", + "app_token": "", + } + self.app.save() + + response = self.request.post( + self.url, {"data": "webhook_payload"}, app_uuid=self.app.uuid + ) + self.assertEqual(response.status_code, 400) From f38bc51dc20170a48e9db6bef406dcedffb87d61 Mon Sep 17 00:00:00 2001 From: elitonzky Date: Fri, 15 Dec 2023 18:25:25 -0300 Subject: [PATCH 12/16] Creates layers for business rules VTEX --- .../services/vtex/business/__init__.py | 0 .../services/vtex/business/rules/__init__.py | 0 .../business/rules/calculate_by_weight.py | 23 ++++++ .../vtex/business/rules/currency_pt_br.py | 15 ++++ .../rules/exclude_alcoholic_drinks.py | 50 +++++++++++++ .../services/vtex/business/rules/interface.py | 8 +++ .../vtex/business/rules/rule_mappings.py | 13 ++++ .../business/rules/unifies_id_with_seller.py | 15 ++++ .../services/vtex/private/products/service.py | 22 ++++-- .../services/vtex/utils/data_processor.py | 72 +++---------------- marketplace/webhooks/vtex/product_updates.py | 4 +- .../vtex/tests/test_product_updates.py | 9 ++- 12 files changed, 162 insertions(+), 69 deletions(-) create mode 100644 marketplace/services/vtex/business/__init__.py create mode 100644 marketplace/services/vtex/business/rules/__init__.py create mode 100644 marketplace/services/vtex/business/rules/calculate_by_weight.py create mode 100644 marketplace/services/vtex/business/rules/currency_pt_br.py create mode 100644 marketplace/services/vtex/business/rules/exclude_alcoholic_drinks.py create mode 100644 marketplace/services/vtex/business/rules/interface.py create mode 100644 marketplace/services/vtex/business/rules/rule_mappings.py create mode 100644 marketplace/services/vtex/business/rules/unifies_id_with_seller.py diff --git a/marketplace/services/vtex/business/__init__.py b/marketplace/services/vtex/business/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/business/rules/__init__.py b/marketplace/services/vtex/business/rules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/vtex/business/rules/calculate_by_weight.py b/marketplace/services/vtex/business/rules/calculate_by_weight.py new file mode 100644 index 00000000..a4df8d6a --- /dev/null +++ b/marketplace/services/vtex/business/rules/calculate_by_weight.py @@ -0,0 +1,23 @@ +from .interface import Rule +from marketplace.services.vtex.utils.data_processor import FacebookProductDTO + + +class CalculateByWeight(Rule): + def apply(self, product: FacebookProductDTO, **kwargs) -> bool: + if self._calculates_by_weight(product): + unit_multiplier = self._get_multiplier(product) + weight = self._get_weight(product) * unit_multiplier + product.price *= unit_multiplier + product.sale_price *= unit_multiplier + product.description += f" - {weight}g" + + return True + + def _calculates_by_weight(self, product: FacebookProductDTO) -> bool: + return product.product_details["MeasurementUnit"] != "un" + + def _get_multiplier(self, product: FacebookProductDTO) -> float: + return product.product_details.get("UnitMultiplier", 1.0) + + def _get_weight(self, product: FacebookProductDTO) -> float: + return product.product_details["Dimension"]["weight"] diff --git a/marketplace/services/vtex/business/rules/currency_pt_br.py b/marketplace/services/vtex/business/rules/currency_pt_br.py new file mode 100644 index 00000000..04cef3c5 --- /dev/null +++ b/marketplace/services/vtex/business/rules/currency_pt_br.py @@ -0,0 +1,15 @@ +from .interface import Rule +from typing import Union +from marketplace.services.vtex.utils.data_processor import FacebookProductDTO + + +class CurrencyBRL(Rule): + def apply(self, product: FacebookProductDTO, **kwargs) -> bool: + product.price = self.format_price(product.price) + product.sale_price = self.format_price(product.sale_price) + return True + + @staticmethod + def format_price(price: Union[int, float]) -> str: + formatted_price = f"{price / 100:.2f} BRL" + return formatted_price diff --git a/marketplace/services/vtex/business/rules/exclude_alcoholic_drinks.py b/marketplace/services/vtex/business/rules/exclude_alcoholic_drinks.py new file mode 100644 index 00000000..bd0257d1 --- /dev/null +++ b/marketplace/services/vtex/business/rules/exclude_alcoholic_drinks.py @@ -0,0 +1,50 @@ +from .interface import Rule +from marketplace.services.vtex.utils.data_processor import FacebookProductDTO + + +class ExcludeAlcoholicDrinks(Rule): + """ + Rule for excluding alcoholic drinks from the product list. + + This rule checks if a given product belongs to the category of alcoholic drinks + and excludes it from the list if it does. The categories defining alcoholic drinks + are specified in the ALCOHOLIC_DRINKS_CATEGORIES set. + + Attributes: + ALCOHOLIC_DRINKS_CATEGORIES (set): A set of category names that identify alcoholic drinks. + """ + + ALCOHOLIC_DRINKS_CATEGORIES = { + "Bebida Alcoólica", + } + + def apply(self, product: FacebookProductDTO, **kwargs) -> bool: + """ + Determines if the product should be excluded based on its categories. + + Args: + product (FacebookProductDTO): The product DTO to be checked. + + Returns: + bool: True if the product does not belong to alcoholic drinks category, False otherwise. + """ + return not self._is_alcoholic_drink(product) + + def _is_alcoholic_drink(self, product: FacebookProductDTO) -> bool: + """ + Checks if the product belongs to any of the alcoholic drinks categories. + + This method compares the product's categories with the predefined set of + alcoholic drinks categories to determine if it is an alcoholic drink. + + Args: + product (FacebookProductDTO): The product DTO to be checked. + + Returns: + bool: True if the product belongs to alcoholic drinks category, False otherwise. + """ + product_categories = set( + product.product_details.get("ProductCategories", {}).values() + ) + # Check if there's any intersection between product categories and alcoholic drinks categories. + return bool(self.ALCOHOLIC_DRINKS_CATEGORIES.intersection(product_categories)) diff --git a/marketplace/services/vtex/business/rules/interface.py b/marketplace/services/vtex/business/rules/interface.py new file mode 100644 index 00000000..62b2efef --- /dev/null +++ b/marketplace/services/vtex/business/rules/interface.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod +from marketplace.services.vtex.utils.data_processor import FacebookProductDTO + + +class Rule(ABC): # TODO: structure order of execution of layers, to avoid conflicts + @abstractmethod + def apply(self, product: FacebookProductDTO, **kwargs) -> bool: + pass diff --git a/marketplace/services/vtex/business/rules/rule_mappings.py b/marketplace/services/vtex/business/rules/rule_mappings.py new file mode 100644 index 00000000..8b4e2146 --- /dev/null +++ b/marketplace/services/vtex/business/rules/rule_mappings.py @@ -0,0 +1,13 @@ +# marketplace/services/vtex/business/rules/rule_mappings.py +from .currency_pt_br import CurrencyBRL +from .calculate_by_weight import CalculateByWeight +from .exclude_alcoholic_drinks import ExcludeAlcoholicDrinks +from .unifies_id_with_seller import UnifiesIdWithSeller + + +RULE_MAPPINGS = { + "currency_pt_br": CurrencyBRL, + "calculate_by_weight": CalculateByWeight, + "exclude_alcoholic_drinks": ExcludeAlcoholicDrinks, + "unifies_id_with_seller": UnifiesIdWithSeller, +} diff --git a/marketplace/services/vtex/business/rules/unifies_id_with_seller.py b/marketplace/services/vtex/business/rules/unifies_id_with_seller.py new file mode 100644 index 00000000..25396fdc --- /dev/null +++ b/marketplace/services/vtex/business/rules/unifies_id_with_seller.py @@ -0,0 +1,15 @@ +from .interface import Rule +from marketplace.services.vtex.utils.data_processor import FacebookProductDTO + + +class UnifiesIdWithSeller(Rule): + SEPARATOR = "#" + + def apply(self, product: FacebookProductDTO, **kwargs) -> bool: + seller_id = kwargs.get("seller_id") + product.id = self.create_unique_product_id(product.id, seller_id) + return True + + @staticmethod + def create_unique_product_id(sku_id: str, seller_id: str) -> str: + return f"{sku_id}{UnifiesIdWithSeller.SEPARATOR}{seller_id}" diff --git a/marketplace/services/vtex/private/products/service.py b/marketplace/services/vtex/private/products/service.py index 712210c2..724f48e5 100644 --- a/marketplace/services/vtex/private/products/service.py +++ b/marketplace/services/vtex/private/products/service.py @@ -32,6 +32,7 @@ """ from marketplace.services.vtex.exceptions import CredentialsValidationError from marketplace.services.vtex.utils.data_processor import DataProcessor +from marketplace.services.vtex.business.rules.rule_mappings import RULE_MAPPINGS class PrivateProductsService: @@ -55,12 +56,12 @@ def validate_private_credentials(self, domain): self.check_is_valid_domain(domain) return self.client.is_valid_credentials(domain) - def list_all_products(self, domain): + def list_all_products(self, domain, config): active_sellers = self.client.list_active_sellers(domain) skus_ids = self.client.list_all_products_sku_ids(domain) - + rules = self._load_rules(config.get("rules", [])) data = self.data_processor.process_product_data( - skus_ids, active_sellers, self, domain + skus_ids, active_sellers, self, domain, rules ) return data @@ -72,7 +73,7 @@ def simulate_cart_for_seller(self, sku_id, seller_id, domain): sku_id, seller_id, domain ) # TODO: Change to pvt_simulate_cart_for_seller - def update_product_info(self, domain, webhook_payload): + def update_product_info(self, domain, webhook_payload, config): updated_products = [] sku_id = webhook_payload["IdSku"] @@ -83,8 +84,9 @@ def update_product_info(self, domain, webhook_payload): seller_ids = self.client.list_active_sellers(domain) if price_modified or stock_modified or other_changes: + rules = self._load_rules(config.get("rules", [])) updated_products_dto = self.data_processor.process_product_data( - [sku_id], seller_ids, self, domain, update_product=True + [sku_id], seller_ids, self, domain, rules, update_product=True ) updated_products = DataProcessor.convert_dtos_to_dicts(updated_products_dto) @@ -97,3 +99,13 @@ def update_product_info(self, domain, webhook_payload): def _is_domain_valid(self, domain): return self.client.check_domain(domain) + + def _load_rules(self, rule_names): + rules = [] + for rule_name in rule_names: + rule_class = RULE_MAPPINGS.get(rule_name) + if rule_class: + rules.append(rule_class()) + else: + print(f"Rule {rule_name} not found or not mapped.") + return rules diff --git a/marketplace/services/vtex/utils/data_processor.py b/marketplace/services/vtex/utils/data_processor.py index c38f73b6..a5f3e97e 100644 --- a/marketplace/services/vtex/utils/data_processor.py +++ b/marketplace/services/vtex/utils/data_processor.py @@ -21,15 +21,6 @@ class FacebookProductDTO: sale_price: str product_details: dict # TODO: Implement ProductDetailsDTO - def get_multiplier(self): - return self.product_details.get("UnitMultiplier", 1.0) - - def get_weight(self): - return self.product_details["Dimension"]["weight"] - - def calculates_by_weight(self): - return self.product_details["MeasurementUnit"] != "un" - @dataclass class VtexProductDTO: # TODO: Implement This VtexProductDTO @@ -37,18 +28,6 @@ class VtexProductDTO: # TODO: Implement This VtexProductDTO class DataProcessor: - SEPARATOR = "#" - CURRENCY = "BRL" - - @staticmethod - def create_unique_product_id(sku_id, seller_id): - return f"{sku_id}{DataProcessor.SEPARATOR}{seller_id}" - - @staticmethod - def decode_unique_product_id(unique_product_id): - sku_id, seller_id = unique_product_id.split(DataProcessor.SEPARATOR) - return sku_id, seller_id - @staticmethod def extract_fields(product_details, availability_details) -> FacebookProductDTO: price = ( @@ -77,60 +56,29 @@ def extract_fields(product_details, availability_details) -> FacebookProductDTO: product_details=product_details, ) - @staticmethod - def format_price(price): - """Formats the price to the standard 'XX.XX BRL'.""" - formatted_price = f"{price / 100:.2f} {DataProcessor.CURRENCY}" # TODO: Move CURRENCY to business layer - return formatted_price - - @staticmethod - def format_fields( - seller_id, product: FacebookProductDTO - ): # TODO: Move this method rules to business layer - if product.calculates_by_weight(): - # Apply price calculation logic per weight/unit - DataProcessor.calculate_price_by_weight(product) - - # Format the price for all products - product.price = DataProcessor.format_price(product.price) - product.sale_price = DataProcessor.format_price(product.sale_price) - product.id = DataProcessor.create_unique_product_id(product.id, seller_id) - - return product - - @staticmethod - def calculate_price_by_weight(product: FacebookProductDTO): - unit_multiplier = product.get_multiplier() - product_weight = product.get_weight() - weight = product_weight * unit_multiplier - product.price = product.price * unit_multiplier - product.sale_price = product.sale_price * unit_multiplier - product.description += f" - {weight}g" - @staticmethod def process_product_data( - skus_ids, active_sellers, service, domain, update_product=False + skus_ids, active_sellers, service, domain, rules, update_product=False ): facebook_products = [] for sku_id in skus_ids: product_details = service.get_product_details(sku_id, domain) - for seller_id in active_sellers: availability_details = service.simulate_cart_for_seller( sku_id, seller_id, domain ) + if update_product is False and not availability_details["is_available"]: + continue - if update_product is False: - if not availability_details["is_available"]: - continue - - extracted_product_dto = DataProcessor.extract_fields( + product_dto = DataProcessor.extract_fields( product_details, availability_details ) - formatted_product_dto = DataProcessor.format_fields( - seller_id, extracted_product_dto - ) - facebook_products.append(formatted_product_dto) + params = {"seller_id": seller_id} + for rule in rules: + if not rule.apply(product_dto, **params): + break + else: + facebook_products.append(product_dto) return facebook_products diff --git a/marketplace/webhooks/vtex/product_updates.py b/marketplace/webhooks/vtex/product_updates.py index 30d9d749..ea703092 100644 --- a/marketplace/webhooks/vtex/product_updates.py +++ b/marketplace/webhooks/vtex/product_updates.py @@ -34,7 +34,9 @@ def post(self, request, app_uuid): domain, app_key, app_token = self.get_credentials_or_raise(app) vtex_service = self.get_vtex_service(app_key, app_token) - products_updated = vtex_service.update_product_info(domain, request.data) + products_updated = vtex_service.update_product_info( + domain, request.data, app.config + ) self.send_products_to_flows(products_updated) return Response(status=status.HTTP_200_OK) diff --git a/marketplace/webhooks/vtex/tests/test_product_updates.py b/marketplace/webhooks/vtex/tests/test_product_updates.py index 7482ed25..0b87a5a8 100644 --- a/marketplace/webhooks/vtex/tests/test_product_updates.py +++ b/marketplace/webhooks/vtex/tests/test_product_updates.py @@ -10,7 +10,7 @@ class MockVtexService: - def update_product_info(self, domain, webhook_payload): + def update_product_info(self, domain, webhook_payload, config): return [{"id": 1, "sku": 1}, {"id": 2, "sku": 2}] @@ -29,9 +29,16 @@ def setUp(self): "app_key": "valid_key", "app_token": "valid_token", } + rules = [ + "calculate_by_weight", + "currency_pt_br", + "exclude_alcoholic_drinks", + "unifies_id_with_seller", + ] config = { "api_credentials": api_credentials, "initial_sync_completed": True, + "rules": rules, } self.app = App.objects.create( code="vtex", From 64ce29e0968ab095c9a15e77a15ad4c4043d41bb Mon Sep 17 00:00:00 2001 From: Eliton Jorge Date: Thu, 21 Dec 2023 14:54:33 -0300 Subject: [PATCH 13/16] [FLOWS-212] - Create catalog linked to the Vtex App (#376) * Create catalog linked to the Vtex App, add services to manipulate Vtex data, adjust the Facebook client --- marketplace/clients/facebook/client.py | 4 +- .../catalogs/tests/test_catalog_views.py | 142 +++++++++++++++++- .../channels/whatsapp_cloud/catalogs/urls.py | 8 +- .../whatsapp_cloud/catalogs/views/views.py | 40 +++++ .../whatsapp_cloud/services/facebook.py | 98 +++++++++--- .../services/tests/test_facebook_service.py | 75 ++++++++- .../migrations/0003_catalog_vtex_app.py | 26 ++++ marketplace/wpp_products/models.py | 8 + marketplace/wpp_products/serializers.py | 1 - 9 files changed, 362 insertions(+), 40 deletions(-) create mode 100644 marketplace/wpp_products/migrations/0003_catalog_vtex_app.py diff --git a/marketplace/clients/facebook/client.py b/marketplace/clients/facebook/client.py index 40b59e64..63f97f6d 100644 --- a/marketplace/clients/facebook/client.py +++ b/marketplace/clients/facebook/client.py @@ -26,7 +26,7 @@ def get_url(self): class FacebookClient(FacebookAuthorization, RequestClient): # Product Catalog - def create_catalog(self, business_id, name, category=None): + def create_catalog(self, business_id, name, category="commerce"): url = self.get_url + f"{business_id}/owned_product_catalogs" data = {"name": name} if category: @@ -71,7 +71,7 @@ def upload_product_feed(self, feed_id, file, update_only=False): ) return response.json() - def create_product_feed_via_url( + def create_product_feed_by_url( self, product_catalog_id, name, feed_url, file_type, interval, hour ): # TODO: adjust this method url = self.get_url + f"{product_catalog_id}/product_feeds" diff --git a/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py b/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py index b662f128..7957b559 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py +++ b/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py @@ -18,6 +18,18 @@ class MockFacebookService: def __init__(self, *args, **kwargs): pass + def create_vtex_catalog(self, validated_data, app, vtex_app, user): + if validated_data["name"] == "valid_catalog": + return (Catalog(app=app, facebook_catalog_id="123456789"), "123456789") + else: + return (None, None) + + def catalog_deletion(self, catalog): + if catalog.facebook_catalog_id == "123456789": + return True + else: + return False + def enable_catalog(self, catalog): return {"success": "True"} @@ -47,6 +59,18 @@ def setUp(self): name="catalog test", category="commerce", ) + self.catalog_success = Catalog.objects.create( + app=self.app, + facebook_catalog_id="123456789", + name="valid_catalog", + category="commerce", + ) + self.catalog_failure = Catalog.objects.create( + app=self.app, + facebook_catalog_id="987654321", + name="invlid_catalog", + category="commerce", + ) self.user_authorization = self.user.authorizations.create( project_uuid=self.app.project_uuid ) @@ -74,13 +98,13 @@ class CatalogListTestCase(MockServiceTestCase): current_view_mapping = {"get": "list"} def test_list_catalogs(self): - url = reverse("catalog-list", kwargs={"app_uuid": self.app.uuid}) + url = reverse("catalog-list-create", kwargs={"app_uuid": self.app.uuid}) response = self.request.get(url, app_uuid=self.app.uuid) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.json["results"]), 1) + self.assertEqual(len(response.json["results"]), 3) def test_filter_by_name(self): - url = reverse("catalog-list", kwargs={"app_uuid": self.app.uuid}) + url = reverse("catalog-list-create", kwargs={"app_uuid": self.app.uuid}) response = self.client.get(url, {"name": "catalog test"}) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -96,7 +120,7 @@ class CatalogRetrieveTestCase(MockServiceTestCase): def test_retreive_catalog(self): url = reverse( - "catalog-detail", + "catalog-detail-delete", kwargs={"app_uuid": self.app.uuid, "catalog_uuid": self.catalog.uuid}, ) response = self.request.get( @@ -146,9 +170,115 @@ def test_list_catalog_with_connected_catalog(self): name="another catalog test", category="commerce", ) - url = reverse("catalog-list", kwargs={"app_uuid": self.app.uuid}) + url = reverse("catalog-list-create", kwargs={"app_uuid": self.app.uuid}) response = self.request.get(url, app_uuid=self.app.uuid) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.json["results"]), 2) + self.assertEqual(len(response.json["results"]), 4) self.assertTrue(response.json["results"][0]["is_connected"]) + + +class CatalogDestroyTestCase(MockServiceTestCase): + current_view_mapping = {"delete": "destroy"} + + def test_delete_catalog_success(self): + url = reverse( + "catalog-detail-delete", + kwargs={"app_uuid": self.app.uuid, "catalog_uuid": self.catalog.uuid}, + ) + + response = self.request.delete( + url, app_uuid=self.app.uuid, catalog_uuid=self.catalog_success.uuid + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_delete_catalog_failure(self): + url = reverse( + "catalog-detail-delete", + kwargs={"app_uuid": self.app.uuid, "catalog_uuid": self.catalog.uuid}, + ) + response = self.request.delete( + url, app_uuid=self.app.uuid, catalog_uuid=self.catalog_failure.uuid + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["detail"], "Failed to delete catalog on Facebook." + ) + + +class CatalogCreateTestCase(MockServiceTestCase): + current_view_mapping = {"post": "create"} + + def setUp(self): + super().setUp() + # Configures a vtex App for an already created wpp-cloud App + self.vtex_app_configured = App.objects.create( + code="vtex", + created_by=self.user, + config={"domain": "valid_domain"}, + configured=True, + project_uuid=self.app.project_uuid, + platform=App.PLATFORM_WENI_FLOWS, + ) + # Creation of a wpp-cloud App to simulate a link with two Vtex Apps + self.app_double_vtex = App.objects.create( + code="wpp-cloud", + created_by=self.user, + project_uuid=str(uuid.uuid4()), + platform=App.PLATFORM_WENI_FLOWS, + ) + # Create a Vtex App 01 and 02 with repeating the project_uuid to simulate duplicity of integration + self.vtex_app_01 = App.objects.create( + code="vtex", + created_by=self.user, + config={"domain": "double_domain"}, + configured=True, + project_uuid=self.app_double_vtex.project_uuid, + platform=App.PLATFORM_WENI_FLOWS, + ) + self.vtex_app_02 = App.objects.create( + code="vtex", + created_by=self.user, + config={"domain": "double_domain"}, + configured=True, + project_uuid=self.app_double_vtex.project_uuid, + platform=App.PLATFORM_WENI_FLOWS, + ) + # Create a wpp-cloud without App-vtex linked to the project + self.app_without_vtex = App.objects.create( + code="wpp-cloud", + created_by=self.user, + project_uuid=str(uuid.uuid4()), + platform=App.PLATFORM_WENI_FLOWS, + ) + + def test_create_catalog_with_vtex_app(self): + data = {"name": "valid_catalog"} + url = reverse("catalog-list-create", kwargs={"app_uuid": self.app.uuid}) + + response = self.request.post(url, data, app_uuid=self.app.uuid) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["facebook_catalog_id"], "123456789") + + def test_create_catalog_with_unconfigured_app(self): + data = {"name": "valid_catalog"} + url = reverse( + "catalog-list-create", kwargs={"app_uuid": self.app_without_vtex.uuid} + ) + + response = self.request.post(url, data, app_uuid=self.app_without_vtex.uuid) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data["detail"], "There is no VTEX App configured.") + + def test_create_catalog_with_multiple_configured_apps(self): + data = {"name": "valid_catalog"} + url = reverse( + "catalog-list-create", kwargs={"app_uuid": self.app_double_vtex.uuid} + ) + + response = self.request.post(url, data, app_uuid=self.app_double_vtex.uuid) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["detail"], + "Multiple VTEX Apps are configured, which is not expected.", + ) diff --git a/marketplace/core/types/channels/whatsapp_cloud/catalogs/urls.py b/marketplace/core/types/channels/whatsapp_cloud/catalogs/urls.py index ca726e93..895347ff 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/catalogs/urls.py +++ b/marketplace/core/types/channels/whatsapp_cloud/catalogs/urls.py @@ -10,13 +10,13 @@ catalog_patterns = [ path( "/catalogs/", - CatalogViewSet.as_view({"get": "list"}), - name="catalog-list", + CatalogViewSet.as_view({"get": "list", "post": "create"}), + name="catalog-list-create", ), path( "/catalogs//", - CatalogViewSet.as_view({"get": "retrieve"}), - name="catalog-detail", + CatalogViewSet.as_view({"get": "retrieve", "delete": "destroy"}), + name="catalog-detail-delete", ), path( "/catalogs//enable/", diff --git a/marketplace/core/types/channels/whatsapp_cloud/catalogs/views/views.py b/marketplace/core/types/channels/whatsapp_cloud/catalogs/views/views.py index 88cd71bb..16c3fe65 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/catalogs/views/views.py +++ b/marketplace/core/types/channels/whatsapp_cloud/catalogs/views/views.py @@ -23,6 +23,7 @@ TresholdSerializer, CatalogListSerializer, ) +from marketplace.services.vtex.generic_service import VtexService class BaseViewSet(viewsets.ModelViewSet): @@ -49,6 +50,17 @@ class Pagination(PageNumberPagination): class CatalogViewSet(BaseViewSet): serializer_class = CatalogSerializer pagination_class = Pagination + vtex_generic_service_class = VtexService + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._vtex_app_service = None + + @property + def vtex_service(self): # pragma: no cover + if not self._vtex_app_service: + self._vtex_app_service = self.vtex_generic_service_class() + return self._vtex_app_service def filter_queryset(self, queryset): params = self.request.query_params @@ -68,6 +80,24 @@ def get_object(self): catalog_uuid = self.kwargs.get("catalog_uuid") return get_object_or_404(queryset, uuid=catalog_uuid) + def create(self, request, app_uuid, *args, **kwargs): + app = get_object_or_404(App, uuid=app_uuid, code="wpp-cloud") + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + vtex_app = self.vtex_service.get_vtex_app_or_error(app.project_uuid) + + catalog, _fba_catalog_id = self.fb_service.create_vtex_catalog( + serializer.validated_data, app, vtex_app, self.request.user + ) + if not catalog: + return Response( + {"detail": "Failed to create catalog on Facebook."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(CatalogSerializer(catalog).data, status=status.HTTP_201_CREATED) + def retrieve(self, request, *args, **kwargs): catalog = self.get_object() connected_catalog_id = self.fb_service.get_connected_catalog(catalog.app) @@ -92,6 +122,16 @@ def list(self, request, *args, **kwargs): return self.get_paginated_response(serialized_data) + def destroy(self, request, *args, **kwargs): + success = self.fb_service.catalog_deletion(self.get_object()) + if not success: + return Response( + {"detail": "Failed to delete catalog on Facebook."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["POST"]) def enable_catalog(self, request, *args, **kwargs): response = self.fb_service.enable_catalog(self.get_object()) diff --git a/marketplace/core/types/channels/whatsapp_cloud/services/facebook.py b/marketplace/core/types/channels/whatsapp_cloud/services/facebook.py index d2c5788a..0b5c85b6 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/services/facebook.py +++ b/marketplace/core/types/channels/whatsapp_cloud/services/facebook.py @@ -10,58 +10,78 @@ client (ClientType): An instance of a client responsible for making requests to Facebook's API. Methods: - get_app_facebook_credentials(app): Retrieves the Facebook credentials from the app's configuration. + create_vtex_catalog(validated_data, app, vtex_app, user): Creates a new catalog associated + with an app and returns the created, - enable_catalog(catalog): Enables a catalog for a given app. + Catalog object along with the Facebook catalog ID. - disable_catalog(catalog): Disables a catalog for a given app. + catalog_deletion(catalog): Deletes a catalog from Facebook and the local database. - get_connected_catalog(app): Gets the currently connected catalog ID for a given app. + enable_catalog(catalog): Enables a catalog for use with WhatsApp Business. - toggle_cart(app, enable=True): Toggles the cart setting for WhatsApp Business. + disable_catalog(catalog): Disables a catalog for use with WhatsApp Business. - toggle_catalog_visibility(app, visible=True): Toggles the visibility of the catalog on WhatsApp Business. + get_connected_catalog(app): Retrieves the ID of the catalog currently connected to a WhatsApp Business account. + + toggle_cart(app, enable=True): Toggles the shopping cart feature for WhatsApp Business. + + toggle_catalog_visibility(app, visible=True): Toggles the visibility of the catalog for WhatsApp Business. wpp_commerce_settings(app): Retrieves the WhatsApp commerce settings associated with a business phone number. +Private Methods: + _get_app_facebook_credentials(app): Retrieves the Facebook credentials from the app's configuration. + + _create_catalog_object(data): Creates a Catalog object in the local database using provided data. + Raises: ValueError: If required Facebook credentials are missing from the app's configuration. """ +from marketplace.wpp_products.models import Catalog class FacebookService: def __init__(self, client): self.client = client - def get_app_facebook_credentials(self, app): - wa_business_id = app.config.get("wa_business_id") - wa_waba_id = app.config.get("wa_waba_id") - wa_phone_number_id = app.config.get("wa_phone_number_id") + # ================================ + # Public Methods + # ================================ - if not wa_business_id or not wa_waba_id or not wa_phone_number_id: - raise ValueError( - "Not found 'wa_waba_id', 'wa_business_id' or wa_phone_number_id in app.config " - ) - return { - "wa_business_id": wa_business_id, - "wa_waba_id": wa_waba_id, - "wa_phone_number_id": wa_phone_number_id, - } + def create_vtex_catalog(self, validated_data, app, vtex_app, user): + business_id = self._get_app_facebook_credentials(app=app).get("wa_business_id") + response = self.client.create_catalog(business_id, validated_data["name"]) + + if response and response.get("id"): + data = { + "app": app, + "facebook_catalog_id": response.get("id"), + "vtex_app": vtex_app, + "name": validated_data["name"], + "created_by": user, + } + catalog = self._create_catalog_object(data) + return catalog, response.get("id") + + return None, None + + def catalog_deletion(self, catalog): + return self.client.destroy_catalog(catalog.facebook_catalog_id) def enable_catalog(self, catalog): - waba_id = self.get_app_facebook_credentials(app=catalog.app).get("wa_waba_id") + waba_id = self._get_app_facebook_credentials(app=catalog.app).get("wa_waba_id") return self.client.enable_catalog( waba_id=waba_id, catalog_id=catalog.facebook_catalog_id ) def disable_catalog(self, catalog): - waba_id = self.get_app_facebook_credentials(app=catalog.app).get("wa_waba_id") + waba_id = self._get_app_facebook_credentials(app=catalog.app).get("wa_waba_id") return self.client.disable_catalog( waba_id=waba_id, catalog_id=catalog.facebook_catalog_id ) def get_connected_catalog(self, app): - waba_id = self.get_app_facebook_credentials(app=app).get("wa_waba_id") + waba_id = self._get_app_facebook_credentials(app=app).get("wa_waba_id") response = self.client.get_connected_catalog(waba_id=waba_id) if len(response.get("data")) > 0: @@ -70,19 +90,47 @@ def get_connected_catalog(self, app): return [] def toggle_cart(self, app, enable=True): - business_phone_number_id = self.get_app_facebook_credentials(app=app).get( + business_phone_number_id = self._get_app_facebook_credentials(app=app).get( "wa_phone_number_id" ) return self.client.toggle_cart(business_phone_number_id, enable) def toggle_catalog_visibility(self, app, visible=True): - business_phone_number_id = self.get_app_facebook_credentials(app=app).get( + business_phone_number_id = self._get_app_facebook_credentials(app=app).get( "wa_phone_number_id" ) return self.client.toggle_catalog_visibility(business_phone_number_id, visible) def wpp_commerce_settings(self, app): - business_phone_number_id = self.get_app_facebook_credentials(app=app).get( + business_phone_number_id = self._get_app_facebook_credentials(app=app).get( "wa_phone_number_id" ) return self.client.get_wpp_commerce_settings(business_phone_number_id) + + # ================================ + # Private Methods + # ================================ + + def _get_app_facebook_credentials(self, app): + wa_business_id = app.config.get("wa_business_id") + wa_waba_id = app.config.get("wa_waba_id") + wa_phone_number_id = app.config.get("wa_phone_number_id") + + if not wa_business_id or not wa_waba_id or not wa_phone_number_id: + raise ValueError( + "Not found 'wa_waba_id', 'wa_business_id' or wa_phone_number_id in app.config " + ) + return { + "wa_business_id": wa_business_id, + "wa_waba_id": wa_waba_id, + "wa_phone_number_id": wa_phone_number_id, + } + + def _create_catalog_object(self, data): + return Catalog.objects.create( + app=data.get("app"), + facebook_catalog_id=data.get("facebook_catalog_id"), + vtex_app=data.get("vtex_app"), + name=data.get("name"), + created_by=data.get("created_by"), + ) diff --git a/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_facebook_service.py b/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_facebook_service.py index cc8f127d..58ac4cd1 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_facebook_service.py +++ b/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_facebook_service.py @@ -14,6 +14,8 @@ class MockClient: + VALID_CATALOGS_ID = ["0123456789010", "1123456789011"] + def enable_catalog(self, waba_id, catalog_id): return {"success": "true"} @@ -48,6 +50,18 @@ def get_wpp_commerce_settings(self, wa_phone_number_id): ] } + def create_catalog(self, business_id, name): + if name == "Valid Catalog": + return {"id": self.VALID_CATALOGS_ID[0]} + else: + return None + + def destroy_catalog(self, catalog_id): + if catalog_id in self.VALID_CATALOGS_ID: + return True + else: + return False + class TestFacebookService(TestCase): def generate_unique_facebook_catalog_id(self): @@ -85,7 +99,7 @@ def test_get_app_facebook_credentials(self): "wa_waba_id": "10101010", "wa_phone_number_id": "0123456789", } - credentials = self.service.get_app_facebook_credentials(self.app) + credentials = self.service._get_app_facebook_credentials(self.app) self.assertEqual(credentials, expected_config) def test_enable_catalog(self): @@ -127,7 +141,7 @@ def test_get_app_facebook_credentials_with_missing_values(self): self.app.config["wa_business_id"] = None self.app.save() with self.assertRaises(ValueError) as context: - self.service.get_app_facebook_credentials(self.app) + self.service._get_app_facebook_credentials(self.app) self.assertIn( "Not found 'wa_waba_id', 'wa_business_id' or wa_phone_number_id in app.config", @@ -136,3 +150,60 @@ def test_get_app_facebook_credentials_with_missing_values(self): self.app.config["wa_business_id"] = "202020202020" self.app.save() + + +class TestFacebookCreateDeleteService(TestCase): + def setUp(self): + self.user = User.objects.create(email="user@example.com") + self.mock_client = MockClient() + self.service = FacebookService(client=self.mock_client) + self.app = App.objects.create( + code="wpp-cloud", + created_by=self.user, + project_uuid=str(uuid.uuid4()), + platform=App.PLATFORM_WENI_FLOWS, + config={ + "wa_business_id": "business-id", + "wa_waba_id": "waba-id", + "wa_phone_number_id": "phone-id", + }, + ) + self.vtex_app = App.objects.create( + code="vtex", + created_by=self.user, + project_uuid=self.app.project_uuid, + platform=App.PLATFORM_WENI_FLOWS, + configured=True, + ) + self.catalog = Catalog.objects.create( + app=self.app, + facebook_catalog_id=MockClient.VALID_CATALOGS_ID[0], + name="Test Catalog", + category="commerce", + ) + + # TODO: resolve error "Key (facebook_catalog_id, app_id)=(0123456789010, 3) already exists." when running tests + # def test_create_vtex_catalog_success(self): + # validated_data = {"name": "Valid Catalog"} + # catalog, fb_catalog_id = self.service.create_vtex_catalog( + # validated_data, self.app, self.vtex_app, self.user + # ) + # self.assertIsNotNone(catalog) + # self.assertEqual(fb_catalog_id, MockClient.VALID_CATALOGS_ID[0]) + + # def test_create_vtex_catalog_failure(self): + # validated_data = {"name": "Invalid Catalog"} + # catalog, fb_catalog_id = self.service.create_vtex_catalog( + # validated_data, self.app, self.vtex_app, self.user + # ) + # self.assertIsNone(catalog) + # self.assertIsNone(fb_catalog_id) + + # def test_catalog_deletion_success(self): + # success = self.service.catalog_deletion(self.catalog) + # self.assertTrue(success) + + # def test_catalog_deletion_failure(self): + # self.catalog.facebook_catalog_id = "invalid-id" + # success = self.service.catalog_deletion(self.catalog) + # self.assertFalse(success) diff --git a/marketplace/wpp_products/migrations/0003_catalog_vtex_app.py b/marketplace/wpp_products/migrations/0003_catalog_vtex_app.py new file mode 100644 index 00000000..032590bd --- /dev/null +++ b/marketplace/wpp_products/migrations/0003_catalog_vtex_app.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.4 on 2023-11-10 17:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("applications", "0016_app_configured"), + ("wpp_products", "0002_auto_20231023_1051"), + ] + + operations = [ + migrations.AddField( + model_name="catalog", + name="vtex_app", + field=models.ForeignKey( + blank=True, + limit_choices_to={"code": "vtex"}, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="vtex_catalogs", + to="applications.app", + ), + ), + ] diff --git a/marketplace/wpp_products/models.py b/marketplace/wpp_products/models.py index 6f4fc369..66d0ea39 100644 --- a/marketplace/wpp_products/models.py +++ b/marketplace/wpp_products/models.py @@ -25,6 +25,14 @@ class Catalog(BaseModel): default=VerticalChoices.ECOMMERCE, ) app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="catalogs") + vtex_app = models.ForeignKey( + App, + on_delete=models.SET_NULL, + related_name="vtex_catalogs", + blank=True, + null=True, + limit_choices_to={"code": "vtex"}, + ) created_by = models.ForeignKey( "accounts.User", on_delete=models.PROTECT, diff --git a/marketplace/wpp_products/serializers.py b/marketplace/wpp_products/serializers.py index 6cb58d59..b95b22b5 100644 --- a/marketplace/wpp_products/serializers.py +++ b/marketplace/wpp_products/serializers.py @@ -6,7 +6,6 @@ class CatalogSerializer(serializers.ModelSerializer): name = serializers.CharField(required=True) facebook_catalog_id = serializers.CharField(read_only=True) - category = serializers.CharField(required=True) is_connected = serializers.SerializerMethodField() class Meta: From 6bf7bf09cf35a57d2ac8b4c54e5d4ddf5d2921a9 Mon Sep 17 00:00:00 2001 From: Eliton Jorge Date: Wed, 3 Jan 2024 10:08:06 -0300 Subject: [PATCH 14/16] [FLOWS-607] After creating the catalog, trigger the first data insertion (#383) * After creating the catalog, trigger the first data insertion, send products to Facebook and save them in integrations database * When sync facebook catalogs sends them to flows * Sends updates on catalog status to flows * Update pyproject.toml * Updates python version from 3.8 to 3.9 within Dockerfile * Update poetry.lock file --- .dockerignore | 2 +- ...d-integrations-engine-push-tag-shared.yaml | 57 +++++- docker-entrypoint.sh | 7 - docker/Dockerfile | 116 +++++++----- docker/start | 40 +++++ gunicorn.conf.py | 2 +- marketplace/clients/facebook/client.py | 16 +- marketplace/clients/flows/client.py | 43 +++++ .../catalogs/tests/test_catalog_views.py | 109 ++++++++++-- .../whatsapp_cloud/catalogs/views/views.py | 61 +++++-- .../channels/whatsapp_cloud/services/flows.py | 10 ++ .../services/tests/test_flows_service.py | 19 +- .../types/ecommerce/vtex/tests/test_views.py | 5 - .../facebook/service.py} | 57 ++---- .../services/facebook/tests/__init__.py | 0 .../facebook}/tests/test_facebook_service.py | 32 ++-- marketplace/services/flows/service.py | 16 +- .../product/product_facebook_manage.py | 33 ++++ marketplace/services/vtex/app_manager.py | 28 +++ marketplace/services/vtex/exceptions.py | 12 ++ marketplace/services/vtex/generic_service.py | 100 +++++++++-- .../services/vtex/private/products/service.py | 17 +- .../vtex/tests/test_generic_service.py | 6 +- .../services/vtex/utils/data_processor.py | 50 +++--- marketplace/webhooks/vtex/product_updates.py | 21 ++- .../vtex/tests/test_product_updates.py | 5 +- .../migrations/0004_product_productfeed.py | 167 ++++++++++++++++++ .../migrations/0005_auto_20231229_1221.py | 24 +++ marketplace/wpp_products/models.py | 51 ++++++ marketplace/wpp_products/tasks.py | 155 ++++++++++++---- poetry.lock | 131 +++++++++++++- pyproject.toml | 3 +- 32 files changed, 1132 insertions(+), 263 deletions(-) delete mode 100755 docker-entrypoint.sh create mode 100644 docker/start rename marketplace/{core/types/channels/whatsapp_cloud/services/facebook.py => services/facebook/service.py} (66%) create mode 100644 marketplace/services/facebook/tests/__init__.py rename marketplace/{core/types/channels/whatsapp_cloud/services => services/facebook}/tests/test_facebook_service.py (90%) create mode 100644 marketplace/services/product/product_facebook_manage.py create mode 100644 marketplace/services/vtex/app_manager.py create mode 100644 marketplace/wpp_products/migrations/0004_product_productfeed.py create mode 100644 marketplace/wpp_products/migrations/0005_auto_20231229_1221.py diff --git a/.dockerignore b/.dockerignore index ca71002d..95ab55ec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,7 +28,7 @@ usr/ node_modules **/node_modules node_modules -docker/ +Dockerfile staticfiles/ *.py[cod] settings.ini diff --git a/.github/workflows/build-integrations-engine-push-tag-shared.yaml b/.github/workflows/build-integrations-engine-push-tag-shared.yaml index 6b02fd74..e3fc36f8 100644 --- a/.github/workflows/build-integrations-engine-push-tag-shared.yaml +++ b/.github/workflows/build-integrations-engine-push-tag-shared.yaml @@ -9,7 +9,7 @@ on: jobs: docker: - runs-on: ubuntu-latest + runs-on: ubuntu-latest steps: - name: Set variables run: | @@ -40,24 +40,63 @@ jobs: - name: Check out the repo uses: actions/checkout@v3 with: - ref: "${{env.GITHUB_SHA}}" + ref: "${{env.GITHUB_SHA}}" + token: ${{ secrets.DEVOPS_GITHUB_PERMANENT_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to ECR - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ secrets.ECR_SHARED }} username: ${{ secrets.AWS_ACCESS_KEY_ID_SHARED }} password: ${{ secrets.AWS_SECRET_ACCESS_KEY_SHARED }} + # Cache + - name: Cache var-cache-apt + uses: actions/cache@v3 + with: + path: var-cache-apt + key: var-cache-apt-${{ hashFiles('docker/Dockerfile') }} + - name: Cache var-lib-apt + uses: actions/cache@v3 + with: + path: var-lib-apt + key: var-lib-apt-${{ hashFiles('docker/Dockerfile') }} + - name: Cache pip + uses: actions/cache@v3 + with: + path: cache-pip + key: cache-pip-${{ hashFiles('docker/Dockerfile') }} + + + # Inject cache + - name: inject var-cache-apt into docker + uses: reproducible-containers/buildkit-cache-dance@v2.1.3 + with: + cache-source: var-cache-apt + cache-target: /var/cache/apt + - name: inject var-lib-apt into docker + uses: reproducible-containers/buildkit-cache-dance@v2.1.3 + with: + cache-source: var-lib-apt + cache-target: /var/lib/apt + - name: inject pip cache into docker + uses: reproducible-containers/buildkit-cache-dance@v2.1.3 + with: + cache-source: cache-pip + cache-target: /pip_cache + + - name: Build and push - Weni Integrations Engine Image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: + cache-from: type=gha + cache-to: type=gha,mode=max context: . labels: | tag=${{env.TAG}} @@ -66,7 +105,8 @@ jobs: file: docker/Dockerfile push: true tags: "${{env.IMAGE_TAG}}" - no-cache: true + #platforms: linux/amd64,linux/arm64,linux/arm/v7,darwin/amd64,linux/arm/v8 + #no-cache: true - name: Check out Kubernetes Manifests uses: actions/checkout@master @@ -109,7 +149,7 @@ jobs: ) echo "Old image version to compare: ${OLD_VERSION}<=${{env.VERSION}}" if verlte "${OLD_VERSION}" "${VERSION}" || [[ ! "${OLD_VERSION}" =~ [0-9]+\.[0-9]+\.[0-9]+ ]] ; then - echo 'New configurations:' + echo 'New configurations for Weni Integrations Engine image:' new_configuration=$( cat "${e}/${{ env.MANIFESTS_PATCH_TARGET }}" \ | jq '(..|select(.path == "/spec/template/spec/containers/0/image")?) += {value: "'"${{env.IMAGE_TAG}}"'"}' @@ -130,4 +170,3 @@ jobs: directory: ./kubernetes-manifests/ branch: main message: "From Weni Integrations Engine Build (Push Tag ${{ env.MANIFESTS_ENVIRONMENT }})" - diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100755 index 956ed3d1..00000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -echo "Running collectstatic" -python manage.py collectstatic --noinput - -echo "Starting server" -gunicorn marketplace.wsgi -c gunicorn.conf.py diff --git a/docker/Dockerfile b/docker/Dockerfile index 82b067d8..605e10c7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,8 @@ -FROM python:3.8-slim-buster AS base +# syntax = docker/dockerfile:1 -ARG APP_UID=1000 -ARG APP_GID=1000 +ARG PYTHON_VERSION="3.9" +ARG DEBIAN_VERSION="buster" +ARG POETRY_VERSION="1.7.0" ARG BUILD_DEPS="\ python3-dev \ @@ -21,64 +22,91 @@ ARG RUNTIME_DEPS="\ gettext \ postgresql-client" -ARG APP_PORT="8000" -ARG APP_VERSION="0.1" +FROM python:${PYTHON_VERSION}-slim-${DEBIAN_VERSION} as base -# set environment variables -ENV PROJECT_PATH="/marketplace" +ARG POETRY_VERSION -ENV APPLICATION_NAME="Marketplace" - -ENV APP_VERSION=${APP_VERSION} \ - RUNTIME_DEPS=${RUNTIME_DEPS} \ - BUILD_DEPS=${BUILD_DEPS} \ - APP_UID=${APP_UID} \ - APP_GID=${APP_GID} \ +ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONIOENCODING=UTF-8 \ + DEBIAN_FRONTEND=noninteractive \ + PROJECT=Marketplace \ + PROJECT_PATH=/marketplace \ + PROJECT_USER=app_user \ + PROJECT_GROUP=app_group \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PATH="/install/bin:${PATH}" \ - APP_PORT=${APP_PORT} + APP_PORT=${APP_PORT} \ + APPLICATION_NAME="Marketplace" \ + RUNTIME_DEPS=${RUNTIME_DEPS} \ + BUILD_DEPS=${BUILD_DEPS} \ + PYTHONIOENCODING=UTF-8 + +ARG COMPRESS_ENABLED +ARG BRANDING_ENABLED + +ARG RAPIDPRO_APPS_GIT_URL +ARG RAPIDPRO_APPS_GIT_BRANCH LABEL app=${VERSION} \ os="debian" \ os.version="10" \ - name="${APPLICATION_NAME} ${APP_VERSION}" \ - description="${APPLICATION_NAME} image" \ - maintainer="${APPLICATION_NAME} Team" + name="Weni-integrations-engine" \ + description="Weni-integrations-engine image" \ + maintainer="https://github.com/weni-ai" \ + org.opencontainers.image.url="https://github.com/weni-ai/weni-integrations-engine" \ + org.opencontainers.image.documentation="https://github.com/weni-ai/weni-integrations-engine" \ + org.opencontainers.image.source="https://github.com/weni-ai/weni-integrations-engine" \ + org.opencontainers.image.title="Weni-integrations-engine" + +RUN addgroup --gid 1999 "${PROJECT_GROUP}" \ + && useradd --system -m -d "${PROJECT_PATH}" -u 1999 -g 1999 "${PROJECT_USER}" + +WORKDIR "${PROJECT_PATH}" + +RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache + +FROM base as build-poetry -RUN addgroup --gid "${APP_GID}" app_group \ - && useradd --system -m -d ${PROJECT_PATH} -u "${APP_UID}" -g "${APP_GID}" app_user +ARG POETRY_VERSION +ARG REQUESTS_VERSION -# set work directory -WORKDIR ${PROJECT_PATH} +COPY pyproject.toml poetry.lock ./ + +RUN --mount=type=cache,mode=0755,target=/pip_cache,id=pip pip install --cache-dir /pip_cache -U poetry=="${POETRY_VERSION}" \ + && poetry cache clear -n --all pypi \ + && poetry export --without-hashes --output requirements.txt +# && poetry add -n --lock $(cat pip-requires.txt) \ + +FROM base as build + +ARG BUILD_DEPS + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install --no-install-recommends --no-install-suggests -y ${BUILD_DEPS} + +COPY --from=build-poetry "${PROJECT_PATH}/requirements.txt" /tmp/dep/ +RUN --mount=type=cache,mode=0755,target=/pip_cache,id=pip pip install --cache-dir /pip_cache --prefix=/install -r /tmp/dep/requirements.txt FROM base -# Clear image and install runtime dependences -RUN apt-get update \ +ARG BUILD_DEPS +ARG RUNTIME_DEPS + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ && SUDO_FORCE_REMOVE=yes apt-get remove --purge -y ${BUILD_DEPS} \ && apt-get autoremove -y \ && apt-get install -y --no-install-recommends ${RUNTIME_DEPS} \ - && rm -rf /usr/share/man \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# TODO: Move it to build stage -RUN if [ ! "x${BUILD_DEPS}" = "x" ] ; then apt-get update \ - && apt-get install -y --no-install-recommends ${BUILD_DEPS} ; fi - -# copy project -COPY --chown=app_user:app_group . . - -# Install project dependencies -RUN python -m pip install --no-cache-dir --upgrade pip \ - && python -m pip install --no-cache-dir -U poetry \ - && poetry config virtualenvs.create false \ - && poetry install --no-dev \ - && pip uninstall --yes poetry + && rm -rf /usr/share/man /usr/share/doc +COPY --from=build /install /usr/local +COPY --chown=${PROJECT_USER}:${PROJECT_GROUP} . ${PROJECT_PATH} -ENTRYPOINT ["/marketplace/docker-entrypoint.sh"] +USER "${PROJECT_USER}:${PROJECT_USER}" +EXPOSE 8000 +ENTRYPOINT ["bash", "./docker/start"] +CMD ["start"] diff --git a/docker/start b/docker/start new file mode 100644 index 00000000..fc6823a8 --- /dev/null +++ b/docker/start @@ -0,0 +1,40 @@ +#!/bin/bash + +export GUNICORN_APP=${GUNICORN_APP:-"marketplace.wsgi"} +export GUNICORN_CONF=${GUNICORN_CONF:-"${PROJECT_PATH}/gunicorn.conf.py"} + +do_gosu(){ + user="$1" + shift 1 + + is_exec="false" + if [ "$1" = "exec" ]; then + is_exec="true" + shift 1 + fi + + if [ "$(id -u)" = "0" ]; then + if [ "${is_exec}" = "true" ]; then + exec gosu "${user}" "$@" + else + gosu "${user}" "$@" + return "$?" + fi + else + if [ "${is_exec}" = "true" ]; then + exec "$@" + else + eval '"$@"' + return "$?" + fi + fi +} + + +if [[ "start" == "$1" ]]; then + echo "Running collectstatic" + do_gosu "${APP_USER}:${APP_GROUP}" python manage.py collectstatic --noinput + echo "Starting server" + do_gosu "${APP_USER}:${APP_GROUP}" exec gunicorn "${GUNICORN_APP}" -c "${GUNICORN_CONF}" +fi +exec "$@" diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 989ba888..ed612a34 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -3,6 +3,6 @@ workers = multiprocessing.cpu_count() * 2 + 1 proc_name = "marketplace" default_proc_name = proc_name -accesslog = "gunicorn.access" +# accesslog = "gunicorn.access" timeout = 120 bind = "0.0.0.0" diff --git a/marketplace/clients/facebook/client.py b/marketplace/clients/facebook/client.py index 63f97f6d..6e08ccd0 100644 --- a/marketplace/clients/facebook/client.py +++ b/marketplace/clients/facebook/client.py @@ -54,15 +54,17 @@ def create_product_feed(self, product_catalog_id, name): return response.json() - def upload_product_feed(self, feed_id, file, update_only=False): + def upload_product_feed( + self, feed_id, file, file_name, file_content_type, update_only=False + ): url = self.get_url + f"{feed_id}/uploads" headers = self._get_headers() files = { "file": ( - file.name, + file_name, file, - file.content_type, + file_content_type, ) } params = {"update_only": update_only} @@ -140,14 +142,18 @@ def list_all_catalogs(self, wa_business_id): url = self.get_url + f"{wa_business_id}/owned_product_catalogs" headers = self._get_headers() all_catalog_ids = [] + all_catalogs = [] while url: response = self.make_request(url, method="GET", headers=headers).json() catalog_data = response.get("data", []) - all_catalog_ids.extend([item["id"] for item in catalog_data]) + for item in catalog_data: + all_catalog_ids.append(item["id"]) + all_catalogs.append(item) + url = response.get("paging", {}).get("next") - return all_catalog_ids + return all_catalog_ids, all_catalogs def destroy_feed(self, feed_id): url = self.get_url + f"{feed_id}" diff --git a/marketplace/clients/flows/client.py b/marketplace/clients/flows/client.py index a14a44db..e2e9e9ad 100644 --- a/marketplace/clients/flows/client.py +++ b/marketplace/clients/flows/client.py @@ -61,3 +61,46 @@ def update_vtex_integration_status(self, project_uuid, user_email, action): json=payload, ) return True + + def update_catalogs(self, flow_object_uuid, catalogs_data): + data = {"data": catalogs_data} + url = f"{self.base_url}/catalogs/{flow_object_uuid}/update-catalog/" + + response = self.make_request( + url, + method="POST", + headers=self.authentication_instance.headers, + json=data, + ) + return response + + def update_status_catalog(self, flow_object_uuid, fba_catalog_id, is_active: bool): + data = { + "facebook_catalog_id": fba_catalog_id, + "is_active": is_active, + } + url = f"{self.base_url}/catalogs/{flow_object_uuid}/update-status-catalog/" + + response = self.make_request( + url, + method="POST", + headers=self.authentication_instance.headers, + json=data, + ) + return response + + def update_vtex_products(self, products, flow_object_uuid, facebook_catalog_id): + data = { + "facebook_catalog_id": facebook_catalog_id, + "products": products, + "channel_uuid": flow_object_uuid, + } + url = f"{self.base_url}/products/update-products/" + + response = self.make_request( + url, + method="POST", + headers=self.authentication_instance.headers, + json=data, + ) + return response diff --git a/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py b/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py index 7957b559..5aabd8df 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py +++ b/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py @@ -31,15 +31,37 @@ def catalog_deletion(self, catalog): return False def enable_catalog(self, catalog): - return {"success": "True"} + return True, {"success": "True"} def disable_catalog(self, catalog): - return {"success": "True"} + return True, {"success": "True"} def get_connected_catalog(self, app): return "0123456789" +class MockFailiedEnableDisableCatalogFacebookService: + def __init__(self, *args, **kwargs): + pass + + def enable_catalog(self, catalog): + return False, {"success": False} + + def disable_catalog(self, catalog): + return False, {"success": False} + + +class MockFlowsService: + def __init__(self, *args, **kwargs): + pass + + def update_catalog_to_active(self, app, fba_catalog_id): + pass + + def update_catalog_to_inactive(self, app, fba_catalog_id): + pass + + class SetUpTestBase(APIBaseTestCase): current_view_mapping = {} view_class = CatalogViewSet @@ -84,14 +106,30 @@ def view(self): class MockServiceTestCase(SetUpTestBase): def setUp(self): super().setUp() - - # Mock service - mock_service = MockFacebookService() - patcher = patch.object( - self.view_class, "fb_service", PropertyMock(return_value=mock_service) + # Mock Celery Task + self.mock_celery_task = patch("marketplace.celery.app.send_task") + self.mock_celery_task.start() + self.addCleanup(self.mock_celery_task.stop) + + # Mock Facebook service + mock_facebook_service = MockFacebookService() + patcher_fb = patch.object( + self.view_class, + "fb_service", + PropertyMock(return_value=mock_facebook_service), + ) + self.addCleanup(patcher_fb.stop) + patcher_fb.start() + + # Mock Flows service + mock_flows_service = MockFlowsService() + patcher_flows = patch.object( + self.view_class, + "flows_service", + PropertyMock(return_value=mock_flows_service), ) - self.addCleanup(patcher.stop) - patcher.start() + self.addCleanup(patcher_flows.stop) + patcher_flows.start() class CatalogListTestCase(MockServiceTestCase): @@ -142,7 +180,25 @@ def test_enable_catalog(self): url, app_uuid=self.app.uuid, catalog_uuid=self.catalog.uuid ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json["success"], "True") + + def test_failed_enable_catalog(self): + mock_facebook_service = MockFailiedEnableDisableCatalogFacebookService() + patcher_fb_failure = patch.object( + self.view_class, + "fb_service", + PropertyMock(return_value=mock_facebook_service), + ) + patcher_fb_failure.start() + self.addCleanup(patcher_fb_failure.stop) + + url = reverse( + "catalog-enable", + kwargs={"app_uuid": self.app.uuid, "catalog_uuid": self.catalog.uuid}, + ) + response = self.request.post( + url, app_uuid=self.app.uuid, catalog_uuid=self.catalog.uuid + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) class CatalogDisableTestCase(MockServiceTestCase): @@ -157,7 +213,25 @@ def test_disable_catalog(self): url, app_uuid=self.app.uuid, catalog_uuid=self.catalog.uuid ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.json["success"], "True") + + def test_failed_disable_catalog(self): + mock_facebook_service = MockFailiedEnableDisableCatalogFacebookService() + patcher_fb_failure = patch.object( + self.view_class, + "fb_service", + PropertyMock(return_value=mock_facebook_service), + ) + patcher_fb_failure.start() + self.addCleanup(patcher_fb_failure.stop) + + url = reverse( + "catalog-disable", + kwargs={"app_uuid": self.app.uuid, "catalog_uuid": self.catalog.uuid}, + ) + response = self.request.post( + url, app_uuid=self.app.uuid, catalog_uuid=self.catalog.uuid + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) class CatalogConnectedTestCase(MockServiceTestCase): @@ -282,3 +356,16 @@ def test_create_catalog_with_multiple_configured_apps(self): response.data["detail"], "Multiple VTEX Apps are configured, which is not expected.", ) + + def test_create_catalog_failure(self): + with patch.object( + MockFacebookService, "create_vtex_catalog", return_value=(None, None) + ): + data = {"name": "valid_catalog"} + url = reverse("catalog-list-create", kwargs={"app_uuid": self.app.uuid}) + + response = self.request.post(url, data, app_uuid=self.app.uuid) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["detail"], "Failed to create catalog on Facebook." + ) diff --git a/marketplace/core/types/channels/whatsapp_cloud/catalogs/views/views.py b/marketplace/core/types/channels/whatsapp_cloud/catalogs/views/views.py index 16c3fe65..74ccc0ae 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/catalogs/views/views.py +++ b/marketplace/core/types/channels/whatsapp_cloud/catalogs/views/views.py @@ -5,7 +5,7 @@ from rest_framework.pagination import PageNumberPagination from rest_framework import status -from marketplace.core.types.channels.whatsapp_cloud.services.facebook import ( +from marketplace.services.facebook.service import ( FacebookService, ) from marketplace.core.types.channels.whatsapp_cloud.services.flows import ( @@ -24,15 +24,20 @@ CatalogListSerializer, ) from marketplace.services.vtex.generic_service import VtexService +from marketplace.celery import app as celery_app class BaseViewSet(viewsets.ModelViewSet): fb_service_class = FacebookService + flows_service_class = FlowsService + fb_client_class = FacebookClient + flows_client_class = FlowsClient def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._fb_service = None + self._flows_service = None @property def fb_service(self): # pragma: no cover @@ -40,6 +45,12 @@ def fb_service(self): # pragma: no cover self._fb_service = self.fb_service_class(self.fb_client_class()) return self._fb_service + @property + def flows_service(self): # pragma: no cover + if not self._flows_service: + self._flows_service = self.flows_service_class(self.flows_client_class()) + return self._flows_service + class Pagination(PageNumberPagination): page_size = 15 @@ -85,7 +96,7 @@ def create(self, request, app_uuid, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) - vtex_app = self.vtex_service.get_vtex_app_or_error(app.project_uuid) + vtex_app = self.vtex_service.app_manager.get_vtex_app_or_error(app.project_uuid) catalog, _fba_catalog_id = self.fb_service.create_vtex_catalog( serializer.validated_data, app, vtex_app, self.request.user @@ -96,6 +107,17 @@ def create(self, request, app_uuid, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + credentials = { + "app_key": vtex_app.config.get("api_credentials", {}).get("app_key"), + "app_token": vtex_app.config.get("api_credentials", {}).get("app_token"), + "domain": vtex_app.config.get("api_credentials", {}).get("domain"), + } + + celery_app.send_task( + name="task_insert_vtex_products", + kwargs={"credentials": credentials, "catalog_uuid": str(catalog.uuid)}, + ) + return Response(CatalogSerializer(catalog).data, status=status.HTTP_201_CREATED) def retrieve(self, request, *args, **kwargs): @@ -134,13 +156,27 @@ def destroy(self, request, *args, **kwargs): @action(detail=True, methods=["POST"]) def enable_catalog(self, request, *args, **kwargs): - response = self.fb_service.enable_catalog(self.get_object()) - return Response(response) + catalog = self.get_object() + success, response = self.fb_service.enable_catalog(catalog) + if not success: + return Response(status=status.HTTP_400_BAD_REQUEST, data=response) + + self.flows_service.update_catalog_to_active( + catalog.app, catalog.facebook_catalog_id + ) + return Response(status=status.HTTP_200_OK) @action(detail=True, methods=["POST"]) def disable_catalog(self, request, *args, **kwargs): - response = self.fb_service.disable_catalog(self.get_object()) - return Response(response) + catalog = self.get_object() + success, response = self.fb_service.disable_catalog(catalog) + if not success: + return Response(status=status.HTTP_400_BAD_REQUEST, data=response) + + self.flows_service.update_catalog_to_inactive( + catalog.app, catalog.facebook_catalog_id + ) + return Response(status=status.HTTP_200_OK) class CommerceSettingsViewSet(BaseViewSet): @@ -182,19 +218,6 @@ def get_active_catalog(self, request, app_uuid, *args, **kwargs): class TresholdViewset(BaseViewSet): serializer_class = TresholdSerializer - flows_service_class = FlowsService - flows_client_class = FlowsClient - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._flows_service = None - - @property - def flows_service(self): # pragma: no cover - if not self._flows_service: - self._flows_service = self.flows_service_class(self.flows_client_class()) - return self._flows_service - @action(detail=True, methods=["POST"]) def update_treshold(self, request, app_uuid, *args, **kwargs): app = get_object_or_404(App, uuid=app_uuid, code="wpp-cloud") diff --git a/marketplace/core/types/channels/whatsapp_cloud/services/flows.py b/marketplace/core/types/channels/whatsapp_cloud/services/flows.py index 81b2b7a6..fb681f57 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/services/flows.py +++ b/marketplace/core/types/channels/whatsapp_cloud/services/flows.py @@ -21,3 +21,13 @@ def update_treshold(self, app, treshold): app.save() return self._update_flows_config(app) + + def update_catalog_to_active(self, app, fba_catalog_id): + return self.client.update_status_catalog( + str(app.flow_object_uuid), fba_catalog_id, is_active=True + ) + + def update_catalog_to_inactive(self, app, fba_catalog_id): + return self.client.update_status_catalog( + str(app.flow_object_uuid), fba_catalog_id, is_active=False + ) diff --git a/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_flows_service.py b/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_flows_service.py index b5cff479..1f22cb80 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_flows_service.py +++ b/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_flows_service.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + from django.test import TestCase from django.contrib.auth import get_user_model @@ -33,7 +35,14 @@ def detail_channel(self, flow_object_uuid): } def update_config(self, data, flow_object_uuid): - return None + mock_response = Mock() + mock_response.status_code = 200 + return mock_response + + def update_status_catalog(self, flow_object_uuid, fba_catalog_id, is_active): + mock_response = Mock() + mock_response.status_code = 200 + return mock_response class TestFlowsService(TestCase): @@ -54,3 +63,11 @@ def setUp(self): def test_update_treshold(self): response = self.service.update_treshold(self.app, 3.5) self.assertEqual(response, True) + + def test_update_catalog_to_active(self): + response = self.service.update_catalog_to_active(self.app, "123456789") + self.assertEqual(response.status_code, 200) + + def test_update_catalog_to_inactive(self): + response = self.service.update_catalog_to_inactive(self.app, "123456789") + self.assertEqual(response.status_code, 200) diff --git a/marketplace/core/types/ecommerce/vtex/tests/test_views.py b/marketplace/core/types/ecommerce/vtex/tests/test_views.py index 951724f1..68b68a3d 100644 --- a/marketplace/core/types/ecommerce/vtex/tests/test_views.py +++ b/marketplace/core/types/ecommerce/vtex/tests/test_views.py @@ -20,11 +20,6 @@ class MockVtexService: - def get_vtex_app_or_error(self, project_uuid): - mock_app = Mock(spec=App) - mock_app.config = {} - return mock_app - def check_is_valid_credentials(self, credentials): return True diff --git a/marketplace/core/types/channels/whatsapp_cloud/services/facebook.py b/marketplace/services/facebook/service.py similarity index 66% rename from marketplace/core/types/channels/whatsapp_cloud/services/facebook.py rename to marketplace/services/facebook/service.py index 0b5c85b6..8ca22d27 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/services/facebook.py +++ b/marketplace/services/facebook/service.py @@ -1,42 +1,3 @@ -""" -Service for interfacing with Facebook APIs. - -This service facilitates the communication with Facebook's APIs, specifically focusing on -WhatsApp Business features and their configurations. The service provides functions for -enabling and disabling catalogs, toggling cart settings, and managing the visibility of -catalogs, among other actions. - -Attributes: - client (ClientType): An instance of a client responsible for making requests to Facebook's API. - -Methods: - create_vtex_catalog(validated_data, app, vtex_app, user): Creates a new catalog associated - with an app and returns the created, - - Catalog object along with the Facebook catalog ID. - - catalog_deletion(catalog): Deletes a catalog from Facebook and the local database. - - enable_catalog(catalog): Enables a catalog for use with WhatsApp Business. - - disable_catalog(catalog): Disables a catalog for use with WhatsApp Business. - - get_connected_catalog(app): Retrieves the ID of the catalog currently connected to a WhatsApp Business account. - - toggle_cart(app, enable=True): Toggles the shopping cart feature for WhatsApp Business. - - toggle_catalog_visibility(app, visible=True): Toggles the visibility of the catalog for WhatsApp Business. - - wpp_commerce_settings(app): Retrieves the WhatsApp commerce settings associated with a business phone number. - -Private Methods: - _get_app_facebook_credentials(app): Retrieves the Facebook credentials from the app's configuration. - - _create_catalog_object(data): Creates a Catalog object in the local database using provided data. - -Raises: - ValueError: If required Facebook credentials are missing from the app's configuration. -""" from marketplace.wpp_products.models import Catalog @@ -70,15 +31,19 @@ def catalog_deletion(self, catalog): def enable_catalog(self, catalog): waba_id = self._get_app_facebook_credentials(app=catalog.app).get("wa_waba_id") - return self.client.enable_catalog( + response = self.client.enable_catalog( waba_id=waba_id, catalog_id=catalog.facebook_catalog_id ) + success = response.get("success") is True + return success, response def disable_catalog(self, catalog): waba_id = self._get_app_facebook_credentials(app=catalog.app).get("wa_waba_id") - return self.client.disable_catalog( + response = self.client.disable_catalog( waba_id=waba_id, catalog_id=catalog.facebook_catalog_id ) + success = response.get("success") is True + return success, response def get_connected_catalog(self, app): waba_id = self._get_app_facebook_credentials(app=app).get("wa_waba_id") @@ -107,6 +72,16 @@ def wpp_commerce_settings(self, app): ) return self.client.get_wpp_commerce_settings(business_phone_number_id) + def create_product_feed(self, product_catalog_id, name): + return self.client.create_product_feed(product_catalog_id, name) + + def upload_product_feed( + self, feed_id, file, file_name, file_content_type, update_only=False + ): + return self.client.upload_product_feed( + feed_id, file, file_name, file_content_type, update_only + ) + # ================================ # Private Methods # ================================ diff --git a/marketplace/services/facebook/tests/__init__.py b/marketplace/services/facebook/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_facebook_service.py b/marketplace/services/facebook/tests/test_facebook_service.py similarity index 90% rename from marketplace/core/types/channels/whatsapp_cloud/services/tests/test_facebook_service.py rename to marketplace/services/facebook/tests/test_facebook_service.py index 58ac4cd1..05ee9ea1 100644 --- a/marketplace/core/types/channels/whatsapp_cloud/services/tests/test_facebook_service.py +++ b/marketplace/services/facebook/tests/test_facebook_service.py @@ -3,7 +3,7 @@ from django.test import TestCase -from marketplace.core.types.channels.whatsapp_cloud.services.facebook import ( +from marketplace.services.facebook.service import ( FacebookService, ) from marketplace.applications.models import App @@ -17,10 +17,10 @@ class MockClient: VALID_CATALOGS_ID = ["0123456789010", "1123456789011"] def enable_catalog(self, waba_id, catalog_id): - return {"success": "true"} + return {"success": True} def disable_catalog(self, waba_id, catalog_id): - return {"success": "true"} + return {"success": True} def get_connected_catalog(self, waba_id): return { @@ -34,17 +34,17 @@ def get_connected_catalog(self, waba_id): } def toggle_cart(self, wa_phone_number_id, enable=True): - return {"success": "true"} + return {"success": True} def toggle_catalog_visibility(self, wa_phone_number_id, make_visible=True): - return {"success": "true"} + return {"success": True} def get_wpp_commerce_settings(self, wa_phone_number_id): return { "data": [ { - "is_cart_enabled": "true", - "is_catalog_visible": "true", + "is_cart_enabled": True, + "is_catalog_visible": True, "id": "012345678901234", } ] @@ -103,12 +103,14 @@ def test_get_app_facebook_credentials(self): self.assertEqual(credentials, expected_config) def test_enable_catalog(self): - response = self.service.enable_catalog(self.catalog) - self.assertEqual(response, {"success": "true"}) + status, response = self.service.enable_catalog(self.catalog) + self.assertEqual(response, {"success": True}) + self.assertEqual(True, status) def test_disable_catalog(self): - response = self.service.disable_catalog(self.catalog) - self.assertEqual(response, {"success": "true"}) + status, response = self.service.disable_catalog(self.catalog) + self.assertEqual(response, {"success": True}) + self.assertEqual(True, status) def test_get_connected_catalog(self): catalog_id = self.service.get_connected_catalog(self.app) @@ -116,16 +118,16 @@ def test_get_connected_catalog(self): def test_toggle_cart(self): response = self.service.toggle_cart(self.app, enable=True) - self.assertEqual(response, {"success": "true"}) + self.assertEqual(response, {"success": True}) def test_toggle_catalog_visibility(self): response = self.service.toggle_catalog_visibility(self.app, visible=True) - self.assertEqual(response, {"success": "true"}) + self.assertEqual(response, {"success": True}) def test_wpp_commerce_settings(self): settings = self.service.wpp_commerce_settings(self.app) - self.assertEqual(settings["data"][0]["is_cart_enabled"], "true") - self.assertEqual(settings["data"][0]["is_catalog_visible"], "true") + self.assertEqual(settings["data"][0]["is_cart_enabled"], True) + self.assertEqual(settings["data"][0]["is_catalog_visible"], True) self.assertEqual(settings["data"][0]["id"], "012345678901234") def test_get_connected_catalog_with_no_data(self): diff --git a/marketplace/services/flows/service.py b/marketplace/services/flows/service.py index ab910d4c..04a07882 100644 --- a/marketplace/services/flows/service.py +++ b/marketplace/services/flows/service.py @@ -7,5 +7,17 @@ def update_vtex_integration_status(self, project_uuid, user_email, action): project_uuid, user_email, action ) - def update_vtex_products(self, products: list): - pass + def update_vtex_products( + self, products: list, flow_object_uuid, facebook_catalog_id + ): + return self.client.update_vtex_products( + products, flow_object_uuid, facebook_catalog_id + ) + + def update_webhook_vtex_products(self, products: list, app): + for catalog in app.catalogs.all(): + self.update_vtex_products( + products, str(app.flow_object_uuid), catalog.facebook_catalog_id + ) + + return True diff --git a/marketplace/services/product/product_facebook_manage.py b/marketplace/services/product/product_facebook_manage.py new file mode 100644 index 00000000..97402cc6 --- /dev/null +++ b/marketplace/services/product/product_facebook_manage.py @@ -0,0 +1,33 @@ +from typing import List + +from django.db import transaction + +from marketplace.services.vtex.utils.data_processor import FacebookProductDTO +from marketplace.wpp_products.models import Product + + +class ProductFacebookManager: + def save_products_on_database( + self, products: List[FacebookProductDTO], catalog, product_feed + ): + product_instances = [ + Product( + facebook_product_id=dto.id, + title=dto.title, + description=dto.description, + availability=dto.availability, + condition=dto.condition, + price=dto.price, + link=dto.link, + image_link=dto.image_link, + brand=dto.brand, + sale_price=dto.sale_price, + catalog=catalog, + created_by=catalog.created_by, + feed=product_feed, + ) + for dto in products + ] + + with transaction.atomic(): + Product.objects.bulk_create(product_instances) diff --git a/marketplace/services/vtex/app_manager.py b/marketplace/services/vtex/app_manager.py new file mode 100644 index 00000000..9beea0e1 --- /dev/null +++ b/marketplace/services/vtex/app_manager.py @@ -0,0 +1,28 @@ +from django.core.exceptions import MultipleObjectsReturned + +from marketplace.services.vtex.exceptions import ( + NoVTEXAppConfiguredException, + MultipleVTEXAppsConfiguredException, +) +from marketplace.applications.models import App + + +class AppVtexManager: + def get_vtex_app_or_error(self, project_uuid): + try: + app_vtex = App.objects.get( + code="vtex", project_uuid=str(project_uuid), configured=True + ) + return app_vtex + except App.DoesNotExist: + raise NoVTEXAppConfiguredException() + except MultipleObjectsReturned: + raise MultipleVTEXAppsConfiguredException() + + def initial_sync_products_completed(self, app: App): + try: + app.config["initial_sync_completed"] = True + app.save() + return True + except Exception as e: + raise e diff --git a/marketplace/services/vtex/exceptions.py b/marketplace/services/vtex/exceptions.py index 6d662dd5..57cad9fa 100644 --- a/marketplace/services/vtex/exceptions.py +++ b/marketplace/services/vtex/exceptions.py @@ -18,3 +18,15 @@ class CredentialsValidationError(APIException): status_code = status.HTTP_400_BAD_REQUEST default_detail = "The credentials provided are invalid." default_code = "invalid_credentials" + + +class FileNotSendValidationError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "The file couldn't be sent. Please try again." + default_code = "file_not_be_sent" + + +class UnexpectedFacebookApiResponseValidationError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Unexpected response from Facebook API." + default_code = "unexpected_response_facebook_api" diff --git a/marketplace/services/vtex/generic_service.py b/marketplace/services/vtex/generic_service.py index 9bc9c5d9..6292178a 100644 --- a/marketplace/services/vtex/generic_service.py +++ b/marketplace/services/vtex/generic_service.py @@ -8,10 +8,6 @@ None Methods: - get_vtex_app_or_error(project_uuid): Retrieves a single configured VTEX App instance - associated with the provided project UUID or raises an exception if not found or if - multiple instances are found. - check_is_valid_credentials(credentials): Validates the provided API credentials against VTEX's services. Raises an exception if the credentials are invalid. @@ -41,8 +37,9 @@ are invalid. """ +from datetime import datetime + from dataclasses import dataclass -from django.core.exceptions import MultipleObjectsReturned from marketplace.applications.models import App from marketplace.services.vtex.private.products.service import ( @@ -51,9 +48,19 @@ from marketplace.clients.vtex.client import VtexPrivateClient from marketplace.services.vtex.exceptions import ( CredentialsValidationError, - NoVTEXAppConfiguredException, - MultipleVTEXAppsConfiguredException, ) +from marketplace.services.facebook.service import ( + FacebookService, +) +from marketplace.clients.facebook.client import FacebookClient +from marketplace.wpp_products.models import ProductFeed +from marketplace.wpp_products.models import Catalog +from marketplace.services.product.product_facebook_manage import ProductFacebookManager +from marketplace.services.vtex.exceptions import ( + UnexpectedFacebookApiResponseValidationError, +) +from marketplace.services.vtex.exceptions import FileNotSendValidationError +from marketplace.services.vtex.app_manager import AppVtexManager @dataclass @@ -71,8 +78,20 @@ def to_dict(self): class VtexService: + fb_service_class = FacebookService + fb_client_class = FacebookClient + def __init__(self, *args, **kwargs): self._pvt_service = None + self._fb_service = None + self.product_manager = ProductFacebookManager() + self.app_manager = AppVtexManager() + + @property + def fb_service(self) -> FacebookService: # pragma: no cover + if not self._fb_service: + self._fb_service = self.fb_service_class(self.fb_client_class()) + return self._fb_service def get_private_service( self, app_key, app_token @@ -82,17 +101,6 @@ def get_private_service( self._pvt_service = PrivateProductsService(client) return self._pvt_service - def get_vtex_app_or_error(self, project_uuid): - try: - app_vtex = App.objects.get( - code="vtex", project_uuid=str(project_uuid), configured=True - ) - return app_vtex - except App.DoesNotExist: - raise NoVTEXAppConfiguredException() - except MultipleObjectsReturned: - raise MultipleVTEXAppsConfiguredException() - def check_is_valid_credentials(self, credentials: APICredentials) -> bool: pvt_service = self.get_private_service( credentials.app_key, credentials.app_token @@ -106,6 +114,62 @@ def configure(self, app, credentials: APICredentials, wpp_cloud_uuid) -> App: app.config["api_credentials"] = credentials.to_dict() app.config["wpp_cloud_uuid"] = wpp_cloud_uuid app.config["initial_sync_completed"] = False + app.config["rules"] = [ + "calculate_by_weight", + "currency_pt_br", + "exclude_alcoholic_drinks", + "unifies_id_with_seller", + ] app.configured = True app.save() return app + + def first_product_insert(self, credentials: APICredentials, catalog: Catalog): + pvt_service = self.get_private_service( + credentials.app_key, credentials.app_token + ) + products = pvt_service.list_all_products( + credentials.domain, catalog.vtex_app.config + ) + products_csv = pvt_service.data_processor.products_to_csv(products) + product_feed = self._send_products_to_facebook(products_csv, catalog) + self.product_manager.save_products_on_database(products, catalog, product_feed) + self.app_manager.initial_sync_products_completed(catalog.vtex_app) + + return pvt_service.data_processor.convert_dtos_to_dicts_list(products) + + def _send_products_to_facebook(self, products_csv, catalog: Catalog): + current_time = datetime.now().strftime("%Y-%m-%d_%H-%M") + file_name = f"csv_vtex_products_{current_time}.csv" + product_feed = self._create_product_feed(file_name, catalog) + self._upload_product_feed( + product_feed.facebook_feed_id, + products_csv, + file_name, + ) + return product_feed + + def _create_product_feed(self, name, catalog: Catalog) -> ProductFeed: + response = self.fb_service.create_product_feed( + catalog.facebook_catalog_id, name + ) + + if "id" not in response: + raise UnexpectedFacebookApiResponseValidationError() + + product_feed = ProductFeed.objects.create( + facebook_feed_id=response["id"], + name=name, + catalog=catalog, + created_by=catalog.created_by, + ) + return product_feed + + def _upload_product_feed(self, product_feed_id, csv_file, file_name): + response = self.fb_service.upload_product_feed( + product_feed_id, csv_file, file_name, "text/csv" + ) + if "id" not in response: + raise FileNotSendValidationError() + + return True diff --git a/marketplace/services/vtex/private/products/service.py b/marketplace/services/vtex/private/products/service.py index 724f48e5..cad61743 100644 --- a/marketplace/services/vtex/private/products/service.py +++ b/marketplace/services/vtex/private/products/service.py @@ -30,9 +30,12 @@ products = service.list_all_products("domain.vtex.com") # Use products data as needed """ +from typing import List + from marketplace.services.vtex.exceptions import CredentialsValidationError from marketplace.services.vtex.utils.data_processor import DataProcessor from marketplace.services.vtex.business.rules.rule_mappings import RULE_MAPPINGS +from marketplace.services.vtex.utils.data_processor import FacebookProductDTO class PrivateProductsService: @@ -56,14 +59,14 @@ def validate_private_credentials(self, domain): self.check_is_valid_domain(domain) return self.client.is_valid_credentials(domain) - def list_all_products(self, domain, config): + def list_all_products(self, domain, config) -> List[FacebookProductDTO]: active_sellers = self.client.list_active_sellers(domain) skus_ids = self.client.list_all_products_sku_ids(domain) rules = self._load_rules(config.get("rules", [])) - data = self.data_processor.process_product_data( + products_dto = self.data_processor.process_product_data( skus_ids, active_sellers, self, domain, rules ) - return data + return products_dto def get_product_details(self, sku_id, domain): return self.client.get_product_details(sku_id, domain) @@ -73,7 +76,9 @@ def simulate_cart_for_seller(self, sku_id, seller_id, domain): sku_id, seller_id, domain ) # TODO: Change to pvt_simulate_cart_for_seller - def update_product_info(self, domain, webhook_payload, config): + def update_product_info( + self, domain, webhook_payload, config + ) -> List[FacebookProductDTO]: updated_products = [] sku_id = webhook_payload["IdSku"] @@ -89,7 +94,9 @@ def update_product_info(self, domain, webhook_payload, config): [sku_id], seller_ids, self, domain, rules, update_product=True ) - updated_products = DataProcessor.convert_dtos_to_dicts(updated_products_dto) + updated_products = DataProcessor.convert_dtos_to_dicts_list( + updated_products_dto + ) return updated_products diff --git a/marketplace/services/vtex/tests/test_generic_service.py b/marketplace/services/vtex/tests/test_generic_service.py index 10c508a6..b79767fc 100644 --- a/marketplace/services/vtex/tests/test_generic_service.py +++ b/marketplace/services/vtex/tests/test_generic_service.py @@ -78,18 +78,18 @@ def setUp(self): ) def test_get_vtex_app_or_error_found(self): - response = self.service.get_vtex_app_or_error(self.project_uuid) + response = self.service.app_manager.get_vtex_app_or_error(self.project_uuid) self.assertEqual(True, response.configured) def test_get_vtex_app_or_error_not_found(self): project_uuid = uuid.uuid4() with self.assertRaises(NoVTEXAppConfiguredException): - self.service.get_vtex_app_or_error(project_uuid) + self.service.app_manager.get_vtex_app_or_error(project_uuid) def test_get_vtex_app_or_error_multiple_found(self): with self.assertRaises(MultipleVTEXAppsConfiguredException): - self.service.get_vtex_app_or_error(self.duplicate_project) + self.service.app_manager.get_vtex_app_or_error(self.duplicate_project) def test_check_is_valid_credentials_valid(self): credentials = APICredentials( diff --git a/marketplace/services/vtex/utils/data_processor.py b/marketplace/services/vtex/utils/data_processor.py index a5f3e97e..f675256a 100644 --- a/marketplace/services/vtex/utils/data_processor.py +++ b/marketplace/services/vtex/utils/data_processor.py @@ -1,4 +1,4 @@ -import csv +import pandas as pd import io import dataclasses @@ -40,6 +40,11 @@ def extract_fields(product_details, availability_details) -> FacebookProductDTO: if availability_details["list_price"] is not None else 0 ) + image_url = ( + product_details.get("Images", [])[0].get("ImageUrl") + if product_details.get("Images") + else product_details.get("ImageUrl") + ) return FacebookProductDTO( id=product_details["Id"], title=product_details["SkuName"], @@ -49,8 +54,8 @@ def extract_fields(product_details, availability_details) -> FacebookProductDTO: else "out of stock", condition="new", price=list_price, - link="", # TODO: Needs Implement This based on FacebookAPI - image_link=product_details["ImageUrl"], + link="https://www.google.com.br/", # TODO: Need to set up the product link. + image_link=image_url, brand=product_details.get("BrandName", "N/A"), sale_price=price, product_details=product_details, @@ -82,29 +87,20 @@ def process_product_data( return facebook_products - def products_to_csv(products: List[FacebookProductDTO]) -> str: - output = io.StringIO() - fieldnames = [ - field.name - for field in dataclasses.fields(FacebookProductDTO) - if field.name != "product_details" - ] - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - for product in products: - row = dataclasses.asdict(product) - row.pop( - "product_details", None - ) # TODO: should change this logic before going to production - writer.writerow(row) - - return output.getvalue() + def products_to_csv(products: List[FacebookProductDTO]) -> io.BytesIO: + product_dicts = [dataclasses.asdict(product) for product in products] + df = pd.DataFrame(product_dicts) + df = df.drop(columns=["product_details"]) + buffer = io.BytesIO() + df.to_csv(buffer, index=False, encoding="utf-8") + buffer.seek(0) + return buffer - @staticmethod - def generate_csv_file(csv_content: str) -> io.BytesIO: - csv_bytes = csv_content.encode("utf-8") - csv_memory = io.BytesIO(csv_bytes) - return csv_memory + def convert_dtos_to_dicts_list(dtos: List[FacebookProductDTO]) -> List[dict]: + dicts_list = [] + for dto in dtos: + dto_dict = dataclasses.asdict(dto) + dto_dict.pop("product_details", None) + dicts_list.append(dto_dict) - def convert_dtos_to_dicts(dtos: List[FacebookProductDTO]) -> List[dict]: - return [dataclasses.asdict(dto) for dto in dtos] + return dicts_list diff --git a/marketplace/webhooks/vtex/product_updates.py b/marketplace/webhooks/vtex/product_updates.py index ea703092..3baf869d 100644 --- a/marketplace/webhooks/vtex/product_updates.py +++ b/marketplace/webhooks/vtex/product_updates.py @@ -17,12 +17,23 @@ class VtexProductUpdateWebhook(APIView): flows_client_class = FlowsClient flows_service_class = FlowsService + vtex_client_class = VtexPrivateClient vtex_service_class = PrivateProductsService authentication_classes = [] permission_classes = [AllowAny] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._flows_service = None + + @property + def flows_service(self): # pragma: no cover + if not self._flows_service: + self._flows_service = self.flows_service_class(self.flows_client_class()) + return self._flows_service + def post(self, request, app_uuid): app = self.get_app(app_uuid) if not self.can_synchronize(app): @@ -30,14 +41,13 @@ def post(self, request, app_uuid): {"error": "initial sync not completed"}, status=status.HTTP_400_BAD_REQUEST, ) - domain, app_key, app_token = self.get_credentials_or_raise(app) vtex_service = self.get_vtex_service(app_key, app_token) - + # TODO: Generate task for update by webhook products_updated = vtex_service.update_product_info( domain, request.data, app.config ) - self.send_products_to_flows(products_updated) + self.flows_service.update_webhook_vtex_products(products_updated, app) return Response(status=status.HTTP_200_OK) def get_app(self, app_uuid): @@ -50,6 +60,7 @@ def can_synchronize(self, app): return app.config.get("initial_sync_completed", False) def get_credentials_or_raise(self, app): + # TODO: Move this to service domain = app.config["api_credentials"]["domain"] app_key = app.config["api_credentials"]["app_key"] app_token = app.config["api_credentials"]["app_token"] @@ -60,7 +71,3 @@ def get_credentials_or_raise(self, app): def get_vtex_service(self, app_key, app_token): client = self.vtex_client_class(app_key, app_token) return self.vtex_service_class(client) - - def send_products_to_flows(self, products): - flows_service = self.flows_service_class(self.flows_client_class()) - flows_service.update_vtex_products(products) diff --git a/marketplace/webhooks/vtex/tests/test_product_updates.py b/marketplace/webhooks/vtex/tests/test_product_updates.py index 0b87a5a8..2018576c 100644 --- a/marketplace/webhooks/vtex/tests/test_product_updates.py +++ b/marketplace/webhooks/vtex/tests/test_product_updates.py @@ -15,9 +15,12 @@ def update_product_info(self, domain, webhook_payload, config): class MockFlowsService: - def update_vtex_products(self, products): + def update_vtex_products(self, products, flow_object_uuid, facebook_catalog_id): return None + def update_webhook_vtex_products(self, products, app): + return True + class SetUpTestBase(APIBaseTestCase): view_class = VtexProductUpdateWebhook diff --git a/marketplace/wpp_products/migrations/0004_product_productfeed.py b/marketplace/wpp_products/migrations/0004_product_productfeed.py new file mode 100644 index 00000000..35fee200 --- /dev/null +++ b/marketplace/wpp_products/migrations/0004_product_productfeed.py @@ -0,0 +1,167 @@ +# Generated by Django 3.2.4 on 2023-12-22 20:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("wpp_products", "0003_catalog_vtex_app"), + ] + + operations = [ + migrations.CreateModel( + name="ProductFeed", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "created_on", + models.DateTimeField(auto_now_add=True, verbose_name="Created on"), + ), + ( + "modified_on", + models.DateTimeField(auto_now=True, verbose_name="Modified on"), + ), + ("facebook_feed_id", models.CharField(max_length=30, unique=True)), + ("name", models.CharField(max_length=100)), + ( + "catalog", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="feeds", + to="wpp_products.catalog", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_productfeeds", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="modified_productfeeds", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "created_on", + models.DateTimeField(auto_now_add=True, verbose_name="Created on"), + ), + ( + "modified_on", + models.DateTimeField(auto_now=True, verbose_name="Modified on"), + ), + ("facebook_product_id", models.CharField(max_length=30, unique=True)), + ("title", models.CharField(max_length=200)), + ("description", models.TextField(max_length=9999)), + ( + "availability", + models.CharField( + choices=[ + ("in stock", "in stock"), + ("out of stock", "out of stock"), + ], + max_length=12, + ), + ), + ( + "condition", + models.CharField( + choices=[ + ("new", "new"), + ("refurbished", "refurbished"), + ("used", "used"), + ], + max_length=11, + ), + ), + ("price", models.CharField(max_length=50)), + ("link", models.URLField()), + ("image_link", models.URLField()), + ("brand", models.CharField(max_length=100)), + ("sale_price", models.CharField(max_length=50)), + ( + "catalog", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="products", + to="wpp_products.catalog", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_products", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "feed", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="products", + to="wpp_products.productfeed", + ), + ), + ( + "modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="modified_products", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/marketplace/wpp_products/migrations/0005_auto_20231229_1221.py b/marketplace/wpp_products/migrations/0005_auto_20231229_1221.py new file mode 100644 index 00000000..8d0b6987 --- /dev/null +++ b/marketplace/wpp_products/migrations/0005_auto_20231229_1221.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.4 on 2023-12-29 15:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("wpp_products", "0004_product_productfeed"), + ] + + operations = [ + migrations.AlterField( + model_name="product", + name="facebook_product_id", + field=models.CharField(max_length=30), + ), + migrations.AddConstraint( + model_name="product", + constraint=models.UniqueConstraint( + fields=("facebook_product_id", "catalog"), + name="unique_facebook_product_id_per_catalog", + ), + ), + ] diff --git a/marketplace/wpp_products/models.py b/marketplace/wpp_products/models.py index 66d0ea39..d43b38fd 100644 --- a/marketplace/wpp_products/models.py +++ b/marketplace/wpp_products/models.py @@ -60,3 +60,54 @@ class Meta: name="unique_facebook_catalog_id_per_app", ) ] + + +class ProductFeed(BaseModel): + facebook_feed_id = models.CharField(max_length=30, unique=True) + name = models.CharField(max_length=100) + catalog = models.ForeignKey(Catalog, on_delete=models.CASCADE, related_name="feeds") + + def __str__(self): + return self.name + + +class Product(BaseModel): + AVAILABILITY_CHOICES = [("in stock", "in stock"), ("out of stock", "out of stock")] + CONDITION_CHOICES = [ + ("new", "new"), + ("refurbished", "refurbished"), + ("used", "used"), + ] + facebook_product_id = models.CharField(max_length=30) + # facebook required fields + title = models.CharField(max_length=200) + description = models.TextField(max_length=9999) + availability = models.CharField(max_length=12, choices=AVAILABILITY_CHOICES) + condition = models.CharField(max_length=11, choices=CONDITION_CHOICES) + price = models.CharField(max_length=50) # Example: "9.99 USD" + link = models.URLField() + image_link = models.URLField() + brand = models.CharField(max_length=100) + sale_price = models.CharField(max_length=50) # Example: "9.99 USD" + + catalog = models.ForeignKey( + Catalog, on_delete=models.CASCADE, related_name="products" + ) + feed = models.ForeignKey( + ProductFeed, + on_delete=models.CASCADE, + related_name="products", + null=True, + blank=True, + ) + + def __str__(self): + return self.title + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["facebook_product_id", "catalog"], + name="unique_facebook_product_id_per_catalog", + ) + ] diff --git a/marketplace/wpp_products/tasks.py b/marketplace/wpp_products/tasks.py index ebe60fde..96a2b73e 100644 --- a/marketplace/wpp_products/tasks.py +++ b/marketplace/wpp_products/tasks.py @@ -1,10 +1,18 @@ import logging +from functools import wraps + from celery import shared_task from marketplace.clients.facebook.client import FacebookClient from marketplace.wpp_products.models import Catalog from marketplace.applications.models import App +from marketplace.clients.flows.client import FlowsClient +from marketplace.celery import app as celery_app +from marketplace.services.vtex.generic_service import VtexService +from marketplace.services.vtex.generic_service import APICredentials +from marketplace.services.flows.service import FlowsService + logger = logging.getLogger(__name__) @@ -13,6 +21,7 @@ def sync_facebook_catalogs(): apps = App.objects.filter(code="wpp-cloud") client = FacebookClient() + flows_client = FlowsClient() for app in apps: wa_business_id = app.config.get("wa_business_id") @@ -23,43 +32,113 @@ def sync_facebook_catalogs(): app.catalogs.values_list("facebook_catalog_id", flat=True) ) + all_catalogs_id, all_catalogs = list_all_catalogs_task(app, client) + + if all_catalogs_id: + update_catalogs_on_flows_task(app, flows_client, all_catalogs) + + fba_catalogs_ids = set(all_catalogs_id) + to_create = fba_catalogs_ids - local_catalog_ids + to_delete = local_catalog_ids - fba_catalogs_ids + + for catalog_id in to_create: + details = get_catalog_details_task(client, app, catalog_id) + if details: + create_catalog_task(app, details) + + if to_delete: + delete_catalogs_task(app, to_delete) + + +def handle_exceptions( + logger, error_msg, continue_on_exception=True, extra_info_func=None +): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): try: - response = client.list_all_catalogs(wa_business_id=wa_business_id) + return func(*args, **kwargs) except Exception as e: - logger.error(f"Error listing all catalogs for app {app.uuid}: {str(e)}") - continue - - fba_catalogs_ids = set(response) - - to_create = fba_catalogs_ids - local_catalog_ids - to_delete = local_catalog_ids - fba_catalogs_ids - - for catalog_id in to_create: - try: - details = client.get_catalog_details(catalog_id) - except Exception as e: - logger.error( - f"Error getting catalog details for app {app.uuid}, catalog {catalog_id}: {str(e)}" - ) - continue - try: - Catalog.objects.create( - app=app, - facebook_catalog_id=details["id"], - name=details["name"], - category=details["vertical"], - ) - except Exception as e: - logger.error( - f"Error creating catalog for app {app.uuid}: {str(e)} , object: {details}" - ) - continue - - if to_delete: - try: - app.catalogs.filter(facebook_catalog_id__in=to_delete).delete() - except Exception as e: - logger.error( - f"Error deleting catalogs for app {app.uuid}: {str(e)}" - ) - continue + extra_info_str = ( + extra_info_func(*args, **kwargs) if extra_info_func else "" + ) + logger.error(f"{error_msg}{extra_info_str}: {str(e)}") + if not continue_on_exception: + raise + + return wrapper + + return decorator + + +def get_extra_info(app, *args, **kwargs): + return f"- UUID: {app.uuid}" + + +@handle_exceptions( + logger, "Error listing all catalogs for App: ", extra_info_func=get_extra_info +) +def list_all_catalogs_task(app, client): + return client.list_all_catalogs(wa_business_id=app.config.get("wa_business_id")) + + +@handle_exceptions( + logger, "Error updating catalogs for App: ", extra_info_func=get_extra_info +) +def update_catalogs_on_flows_task(app, flows_client, all_catalogs): + flows_client.update_catalogs(str(app.flow_object_uuid), all_catalogs) + + +@handle_exceptions( + logger, + "Error getting catalog details for App", + continue_on_exception=False, + extra_info_func=get_extra_info, +) +def get_catalog_details_task(client, app, catalog_id): + return client.get_catalog_details(catalog_id) + + +@handle_exceptions( + logger, "Error creating catalog for App: ", extra_info_func=get_extra_info +) +def create_catalog_task(app, details): + Catalog.objects.create( + app=app, + facebook_catalog_id=details["id"], + name=details["name"], + category=details["vertical"], + ) + + +@handle_exceptions( + logger, "Error deleting catalogs for App: ", extra_info_func=get_extra_info +) +def delete_catalogs_task(app, to_delete): + app.catalogs.filter(facebook_catalog_id__in=to_delete).delete() + + +@celery_app.task(name="task_insert_vtex_products") +def task_insert_vtex_products(**kwargs): + vtex_service = VtexService() + flows_service = FlowsService(FlowsClient()) + + credentials = kwargs.get("credentials") + catalog_uuid = kwargs.get("catalog_uuid") + + catalog = Catalog.objects.get(uuid=catalog_uuid) + try: + api_credentials = APICredentials( + app_key=credentials.get("app_key"), + app_token=credentials.get("app_token"), + domain=credentials.get("domain"), + ) + products = vtex_service.first_product_insert(api_credentials, catalog) + flows_service.update_vtex_products( + products, str(catalog.app.flow_object_uuid), catalog.facebook_catalog_id + ) + print("Products created and sent to flows successfully") + except Exception as e: + logger.error( + f"Error on insert vtex products for catalog {str(catalog.uuid)}, {e}" + ) diff --git a/poetry.lock b/poetry.lock index 316155ee..f47d7fac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -641,6 +641,14 @@ category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +[[package]] +name = "numpy" +version = "1.26.2" +description = "Fundamental package for array computing in Python" +category = "main" +optional = false +python-versions = ">=3.9" + [[package]] name = "packaging" version = "21.3" @@ -652,6 +660,48 @@ python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" +[[package]] +name = "pandas" +version = "2.1.4" +description = "Powerful data structures for data analysis, time series, and statistics" +category = "main" +optional = false +python-versions = ">=3.9" + +[package.dependencies] +numpy = [ + {version = ">=1.22.4,<2", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2,<2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0,<2", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.1" + +[package.extras] +all = ["PyQt5 (>=5.15.6)", "SQLAlchemy (>=1.4.36)", "beautifulsoup4 (>=4.11.1)", "bottleneck (>=1.3.4)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=0.8.1)", "fsspec (>=2022.05.0)", "gcsfs (>=2022.05.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.8.0)", "matplotlib (>=3.6.1)", "numba (>=0.55.2)", "numexpr (>=2.8.0)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pandas-gbq (>=0.17.5)", "psycopg2 (>=2.9.3)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.5)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "pyxlsb (>=1.0.9)", "qtpy (>=2.2.0)", "s3fs (>=2022.05.0)", "scipy (>=1.8.1)", "tables (>=3.7.0)", "tabulate (>=0.8.10)", "xarray (>=2022.03.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)", "zstandard (>=0.17.0)"] +aws = ["s3fs (>=2022.05.0)"] +clipboard = ["PyQt5 (>=5.15.6)", "qtpy (>=2.2.0)"] +compression = ["zstandard (>=0.17.0)"] +computation = ["scipy (>=1.8.1)", "xarray (>=2022.03.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pyxlsb (>=1.0.9)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)"] +feather = ["pyarrow (>=7.0.0)"] +fss = ["fsspec (>=2022.05.0)"] +gcp = ["gcsfs (>=2022.05.0)", "pandas-gbq (>=0.17.5)"] +hdf5 = ["tables (>=3.7.0)"] +html = ["beautifulsoup4 (>=4.11.1)", "html5lib (>=1.1)", "lxml (>=4.8.0)"] +mysql = ["SQLAlchemy (>=1.4.36)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.8.10)"] +parquet = ["pyarrow (>=7.0.0)"] +performance = ["bottleneck (>=1.3.4)", "numba (>=0.55.2)", "numexpr (>=2.8.0)"] +plot = ["matplotlib (>=3.6.1)"] +postgresql = ["SQLAlchemy (>=1.4.36)", "psycopg2 (>=2.9.3)"] +spss = ["pyreadstat (>=1.1.5)"] +sql-other = ["SQLAlchemy (>=1.4.36)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.8.0)"] + [[package]] name = "pathspec" version = "0.9.0" @@ -952,6 +1002,14 @@ category = "dev" optional = false python-versions = ">=3.7" +[[package]] +name = "tzdata" +version = "2023.4" +description = "Provider of IANA time zone data" +category = "main" +optional = false +python-versions = ">=2" + [[package]] name = "uritemplate" version = "4.1.1" @@ -1020,8 +1078,8 @@ protobuf = ">=3.17.3,<4.0.0" [metadata] lock-version = "1.1" -python-versions = "^3.8" -content-hash = "58a096cd4d44e8340b8c548d54d0deced1fde572b87f37ffc2e63b8e60a141fa" +python-versions = "^3.9" +content-hash = "8f65795896bb7581c483bf2d33904c0bf9538dd9c58c0e1918b901c51a7297ed" [metadata.files] amqp = [ @@ -1495,10 +1553,75 @@ nodeenv = [ {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, ] +numpy = [ + {file = "numpy-1.26.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3703fc9258a4a122d17043e57b35e5ef1c5a5837c3db8be396c82e04c1cf9b0f"}, + {file = "numpy-1.26.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc392fdcbd21d4be6ae1bb4475a03ce3b025cd49a9be5345d76d7585aea69440"}, + {file = "numpy-1.26.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36340109af8da8805d8851ef1d74761b3b88e81a9bd80b290bbfed61bd2b4f75"}, + {file = "numpy-1.26.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc008217145b3d77abd3e4d5ef586e3bdfba8fe17940769f8aa09b99e856c00"}, + {file = "numpy-1.26.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3ced40d4e9e18242f70dd02d739e44698df3dcb010d31f495ff00a31ef6014fe"}, + {file = "numpy-1.26.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b272d4cecc32c9e19911891446b72e986157e6a1809b7b56518b4f3755267523"}, + {file = "numpy-1.26.2-cp310-cp310-win32.whl", hash = "sha256:22f8fc02fdbc829e7a8c578dd8d2e15a9074b630d4da29cda483337e300e3ee9"}, + {file = "numpy-1.26.2-cp310-cp310-win_amd64.whl", hash = "sha256:26c9d33f8e8b846d5a65dd068c14e04018d05533b348d9eaeef6c1bd787f9919"}, + {file = "numpy-1.26.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b96e7b9c624ef3ae2ae0e04fa9b460f6b9f17ad8b4bec6d7756510f1f6c0c841"}, + {file = "numpy-1.26.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa18428111fb9a591d7a9cc1b48150097ba6a7e8299fb56bdf574df650e7d1f1"}, + {file = "numpy-1.26.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06fa1ed84aa60ea6ef9f91ba57b5ed963c3729534e6e54055fc151fad0423f0a"}, + {file = "numpy-1.26.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96ca5482c3dbdd051bcd1fce8034603d6ebfc125a7bd59f55b40d8f5d246832b"}, + {file = "numpy-1.26.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:854ab91a2906ef29dc3925a064fcd365c7b4da743f84b123002f6139bcb3f8a7"}, + {file = "numpy-1.26.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f43740ab089277d403aa07567be138fc2a89d4d9892d113b76153e0e412409f8"}, + {file = "numpy-1.26.2-cp311-cp311-win32.whl", hash = "sha256:a2bbc29fcb1771cd7b7425f98b05307776a6baf43035d3b80c4b0f29e9545186"}, + {file = "numpy-1.26.2-cp311-cp311-win_amd64.whl", hash = "sha256:2b3fca8a5b00184828d12b073af4d0fc5fdd94b1632c2477526f6bd7842d700d"}, + {file = "numpy-1.26.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a4cd6ed4a339c21f1d1b0fdf13426cb3b284555c27ac2f156dfdaaa7e16bfab0"}, + {file = "numpy-1.26.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d5244aabd6ed7f312268b9247be47343a654ebea52a60f002dc70c769048e75"}, + {file = "numpy-1.26.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a3cdb4d9c70e6b8c0814239ead47da00934666f668426fc6e94cce869e13fd7"}, + {file = "numpy-1.26.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa317b2325f7aa0a9471663e6093c210cb2ae9c0ad824732b307d2c51983d5b6"}, + {file = "numpy-1.26.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:174a8880739c16c925799c018f3f55b8130c1f7c8e75ab0a6fa9d41cab092fd6"}, + {file = "numpy-1.26.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f79b231bf5c16b1f39c7f4875e1ded36abee1591e98742b05d8a0fb55d8a3eec"}, + {file = "numpy-1.26.2-cp312-cp312-win32.whl", hash = "sha256:4a06263321dfd3598cacb252f51e521a8cb4b6df471bb12a7ee5cbab20ea9167"}, + {file = "numpy-1.26.2-cp312-cp312-win_amd64.whl", hash = "sha256:b04f5dc6b3efdaab541f7857351aac359e6ae3c126e2edb376929bd3b7f92d7e"}, + {file = "numpy-1.26.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4eb8df4bf8d3d90d091e0146f6c28492b0be84da3e409ebef54349f71ed271ef"}, + {file = "numpy-1.26.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1a13860fdcd95de7cf58bd6f8bc5a5ef81c0b0625eb2c9a783948847abbef2c2"}, + {file = "numpy-1.26.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64308ebc366a8ed63fd0bf426b6a9468060962f1a4339ab1074c228fa6ade8e3"}, + {file = "numpy-1.26.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baf8aab04a2c0e859da118f0b38617e5ee65d75b83795055fb66c0d5e9e9b818"}, + {file = "numpy-1.26.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d73a3abcac238250091b11caef9ad12413dab01669511779bc9b29261dd50210"}, + {file = "numpy-1.26.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b361d369fc7e5e1714cf827b731ca32bff8d411212fccd29ad98ad622449cc36"}, + {file = "numpy-1.26.2-cp39-cp39-win32.whl", hash = "sha256:bd3f0091e845164a20bd5a326860c840fe2af79fa12e0469a12768a3ec578d80"}, + {file = "numpy-1.26.2-cp39-cp39-win_amd64.whl", hash = "sha256:2beef57fb031dcc0dc8fa4fe297a742027b954949cabb52a2a376c144e5e6060"}, + {file = "numpy-1.26.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1cc3d5029a30fb5f06704ad6b23b35e11309491c999838c31f124fee32107c79"}, + {file = "numpy-1.26.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94cc3c222bb9fb5a12e334d0479b97bb2df446fbe622b470928f5284ffca3f8d"}, + {file = "numpy-1.26.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe6b44fb8fcdf7eda4ef4461b97b3f63c466b27ab151bec2366db8b197387841"}, + {file = "numpy-1.26.2.tar.gz", hash = "sha256:f65738447676ab5777f11e6bbbdb8ce11b785e105f690bc45966574816b6d3ea"}, +] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] +pandas = [ + {file = "pandas-2.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bdec823dc6ec53f7a6339a0e34c68b144a7a1fd28d80c260534c39c62c5bf8c9"}, + {file = "pandas-2.1.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:294d96cfaf28d688f30c918a765ea2ae2e0e71d3536754f4b6de0ea4a496d034"}, + {file = "pandas-2.1.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b728fb8deba8905b319f96447a27033969f3ea1fea09d07d296c9030ab2ed1d"}, + {file = "pandas-2.1.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00028e6737c594feac3c2df15636d73ace46b8314d236100b57ed7e4b9ebe8d9"}, + {file = "pandas-2.1.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:426dc0f1b187523c4db06f96fb5c8d1a845e259c99bda74f7de97bd8a3bb3139"}, + {file = "pandas-2.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:f237e6ca6421265643608813ce9793610ad09b40154a3344a088159590469e46"}, + {file = "pandas-2.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b7d852d16c270e4331f6f59b3e9aa23f935f5c4b0ed2d0bc77637a8890a5d092"}, + {file = "pandas-2.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd7d5f2f54f78164b3d7a40f33bf79a74cdee72c31affec86bfcabe7e0789821"}, + {file = "pandas-2.1.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0aa6e92e639da0d6e2017d9ccff563222f4eb31e4b2c3cf32a2a392fc3103c0d"}, + {file = "pandas-2.1.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d797591b6846b9db79e65dc2d0d48e61f7db8d10b2a9480b4e3faaddc421a171"}, + {file = "pandas-2.1.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2d3e7b00f703aea3945995ee63375c61b2e6aa5aa7871c5d622870e5e137623"}, + {file = "pandas-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc9bf7ade01143cddc0074aa6995edd05323974e6e40d9dbde081021ded8510e"}, + {file = "pandas-2.1.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:482d5076e1791777e1571f2e2d789e940dedd927325cc3cb6d0800c6304082f6"}, + {file = "pandas-2.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8a706cfe7955c4ca59af8c7a0517370eafbd98593155b48f10f9811da440248b"}, + {file = "pandas-2.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0513a132a15977b4a5b89aabd304647919bc2169eac4c8536afb29c07c23540"}, + {file = "pandas-2.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9f17f2b6fc076b2a0078862547595d66244db0f41bf79fc5f64a5c4d635bead"}, + {file = "pandas-2.1.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:45d63d2a9b1b37fa6c84a68ba2422dc9ed018bdaa668c7f47566a01188ceeec1"}, + {file = "pandas-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:f69b0c9bb174a2342818d3e2778584e18c740d56857fc5cdb944ec8bbe4082cf"}, + {file = "pandas-2.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3f06bda01a143020bad20f7a85dd5f4a1600112145f126bc9e3e42077c24ef34"}, + {file = "pandas-2.1.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab5796839eb1fd62a39eec2916d3e979ec3130509930fea17fe6f81e18108f6a"}, + {file = "pandas-2.1.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edbaf9e8d3a63a9276d707b4d25930a262341bca9874fcb22eff5e3da5394732"}, + {file = "pandas-2.1.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ebfd771110b50055712b3b711b51bee5d50135429364d0498e1213a7adc2be8"}, + {file = "pandas-2.1.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8ea107e0be2aba1da619cc6ba3f999b2bfc9669a83554b1904ce3dd9507f0860"}, + {file = "pandas-2.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:d65148b14788b3758daf57bf42725caa536575da2b64df9964c563b015230984"}, + {file = "pandas-2.1.4.tar.gz", hash = "sha256:fcb68203c833cc735321512e13861358079a96c174a61f5116a1de89c58c0ef7"}, +] pathspec = [ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, @@ -1747,6 +1870,10 @@ typing-extensions = [ {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, ] +tzdata = [ + {file = "tzdata-2023.4-py2.py3-none-any.whl", hash = "sha256:aa3ace4329eeacda5b7beb7ea08ece826c28d761cda36e747cfbf97996d39bf3"}, + {file = "tzdata-2023.4.tar.gz", hash = "sha256:dd54c94f294765522c77399649b4fefd95522479a664a0cec87f41bebc6148c9"}, +] uritemplate = [ {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, diff --git a/pyproject.toml b/pyproject.toml index 6fe17cfb..c901e135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Marketplace engine" authors = ["Weni"] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" Django = "3.2.4" django-environ = "0.4.5" psycopg2 = "^2.8.6" @@ -31,6 +31,7 @@ phonenumbers = "^8.12.47" click = "7.1.2" elastic-apm = "^6.10.1" pre-commit = "2.20.0" +pandas = "^2.1.4" [tool.poetry.dev-dependencies] black = "^21.5b2" From dc5d5bfc52b33167e014e793cf991e261a56d2de Mon Sep 17 00:00:00 2001 From: elitonzky Date: Fri, 5 Jan 2024 09:04:49 -0300 Subject: [PATCH 15/16] Sends catalog data to flows --- marketplace/clients/flows/client.py | 7 +++---- marketplace/services/flows/service.py | 6 ++---- marketplace/wpp_products/tasks.py | 6 +++++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/marketplace/clients/flows/client.py b/marketplace/clients/flows/client.py index e2e9e9ad..3ae08b82 100644 --- a/marketplace/clients/flows/client.py +++ b/marketplace/clients/flows/client.py @@ -89,14 +89,13 @@ def update_status_catalog(self, flow_object_uuid, fba_catalog_id, is_active: boo ) return response - def update_vtex_products(self, products, flow_object_uuid, facebook_catalog_id): + def update_vtex_products(self, products, flow_object_uuid, dict_catalog): data = { - "facebook_catalog_id": facebook_catalog_id, - "products": products, + "catalog": dict_catalog, "channel_uuid": flow_object_uuid, + "products": products, } url = f"{self.base_url}/products/update-products/" - response = self.make_request( url, method="POST", diff --git a/marketplace/services/flows/service.py b/marketplace/services/flows/service.py index 04a07882..b3f272e5 100644 --- a/marketplace/services/flows/service.py +++ b/marketplace/services/flows/service.py @@ -7,11 +7,9 @@ def update_vtex_integration_status(self, project_uuid, user_email, action): project_uuid, user_email, action ) - def update_vtex_products( - self, products: list, flow_object_uuid, facebook_catalog_id - ): + def update_vtex_products(self, products: list, flow_object_uuid, dict_catalog): return self.client.update_vtex_products( - products, flow_object_uuid, facebook_catalog_id + products, flow_object_uuid, dict_catalog ) def update_webhook_vtex_products(self, products: list, app): diff --git a/marketplace/wpp_products/tasks.py b/marketplace/wpp_products/tasks.py index 96a2b73e..caf72282 100644 --- a/marketplace/wpp_products/tasks.py +++ b/marketplace/wpp_products/tasks.py @@ -134,8 +134,12 @@ def task_insert_vtex_products(**kwargs): domain=credentials.get("domain"), ) products = vtex_service.first_product_insert(api_credentials, catalog) + dict_catalog = { + "name": catalog.name, + "facebook_catalog_id": catalog.facebook_catalog_id, + } flows_service.update_vtex_products( - products, str(catalog.app.flow_object_uuid), catalog.facebook_catalog_id + products, str(catalog.app.flow_object_uuid), dict_catalog ) print("Products created and sent to flows successfully") except Exception as e: From 61ebb79f8e470c39418bc4c1c2faced793c52449 Mon Sep 17 00:00:00 2001 From: Eliton Jorge Date: Mon, 8 Jan 2024 13:28:35 -0300 Subject: [PATCH 16/16] Send product csv file to Facebook, save it in integrations or update it and send it to flows (#386) --- .../product/product_facebook_manage.py | 66 +++++++++++++++ marketplace/services/vtex/generic_service.py | 82 ++++++++++--------- .../services/vtex/private/products/service.py | 10 +-- .../services/vtex/utils/data_processor.py | 8 +- marketplace/webhooks/vtex/product_updates.py | 42 ++-------- .../vtex/tests/test_product_updates.py | 50 +---------- marketplace/wpp_products/tasks.py | 44 ++++++++++ 7 files changed, 172 insertions(+), 130 deletions(-) diff --git a/marketplace/services/product/product_facebook_manage.py b/marketplace/services/product/product_facebook_manage.py index 97402cc6..4449c55d 100644 --- a/marketplace/services/product/product_facebook_manage.py +++ b/marketplace/services/product/product_facebook_manage.py @@ -1,11 +1,15 @@ from typing import List from django.db import transaction +from django.contrib.auth import get_user_model from marketplace.services.vtex.utils.data_processor import FacebookProductDTO from marketplace.wpp_products.models import Product +User = get_user_model() + + class ProductFacebookManager: def save_products_on_database( self, products: List[FacebookProductDTO], catalog, product_feed @@ -31,3 +35,65 @@ def save_products_on_database( with transaction.atomic(): Product.objects.bulk_create(product_instances) + + return True + + def update_products_on_database( + self, products: List[FacebookProductDTO], catalog, product_feed + ): + products_to_update = [] + products_to_create = [] + + for dto in products: + try: + product = Product.objects.get( + facebook_product_id=dto.id, catalog=catalog, feed=product_feed + ) # TODO: Optimize to make a single query at the bank + product.title = dto.title + product.description = dto.description + product.availability = dto.availability + product.condition = dto.condition + product.price = dto.price + product.link = dto.link + product.image_link = dto.image_link + product.brand = dto.brand + product.sale_price = dto.sale_price + products_to_update.append(product) + except Product.DoesNotExist: + new_product = Product( + facebook_product_id=dto.id, + title=dto.title, + description=dto.description, + availability=dto.availability, + condition=dto.condition, + price=dto.price, + link=dto.link, + image_link=dto.image_link, + brand=dto.brand, + sale_price=dto.sale_price, + catalog=catalog, + created_by=User.objects.get_admin_user(), + feed=product_feed, + ) + products_to_create.append(new_product) + + if products_to_update: + # Bulk update + fields_to_update = [ + "title", + "description", + "availability", + "condition", + "price", + "link", + "image_link", + "brand", + "sale_price", + ] + Product.objects.bulk_update(products_to_update, fields_to_update) + + # Bulk create + if products_to_create: + Product.objects.bulk_create(products_to_create) + + return True diff --git a/marketplace/services/vtex/generic_service.py b/marketplace/services/vtex/generic_service.py index 6292178a..eb31d9b7 100644 --- a/marketplace/services/vtex/generic_service.py +++ b/marketplace/services/vtex/generic_service.py @@ -1,41 +1,5 @@ """ Service for managing VTEX App instances within a project. - -This service provides methods to retrieve a configured VTEX App instance by its project UUID, -validate API credentials, and configure a VTEX App instance with provided credentials. - -Attributes: - None - -Methods: - check_is_valid_credentials(credentials): Validates the provided API credentials against - VTEX's services. Raises an exception if the credentials are invalid. - - configure(app, credentials): Configures a VTEX App instance with the provided API credentials. - Updates the app configuration and marks it as configured. - -Private Methods: - _update_config(app, key, data): Updates the configuration of the given App instance - with the provided data under the specified configuration key. - -Raises: - NoVTEXAppConfiguredException: If no VTEX App is configured for the given project UUID. - MultipleVTEXAppsConfiguredException: If multiple configured VTEX Apps are found for the - given project UUID, which is unexpected behavior. - CredentialsValidationError: If the provided API credentials are found to be invalid during - validation. - -Data Classes: - APICredentials: Data class that holds the structure for VTEX API credentials. - -Exceptions: - NoVTEXAppConfiguredException: Raised as an HTTP 404 Not Found if no VTEX App is configured - for the given project UUID. - MultipleVTEXAppsConfiguredException: Raised as an HTTP 400 Bad Request if multiple configured - VTEX Apps are found for the given project UUID, which is unexpected behavior. - CredentialsValidationError: Raised as an HTTP 400 Bad Request if provided API credentials - are invalid. - """ from datetime import datetime @@ -124,6 +88,15 @@ def configure(self, app, credentials: APICredentials, wpp_cloud_uuid) -> App: app.save() return app + def get_vtex_credentials_or_raise(self, app): + domain = app.config["api_credentials"]["domain"] + app_key = app.config["api_credentials"]["app_key"] + app_token = app.config["api_credentials"]["app_token"] + if not domain or not app_key or not app_token: + raise CredentialsValidationError() + + return domain, app_key, app_token + def first_product_insert(self, credentials: APICredentials, catalog: Catalog): pvt_service = self.get_private_service( credentials.app_key, credentials.app_token @@ -138,6 +111,25 @@ def first_product_insert(self, credentials: APICredentials, catalog: Catalog): return pvt_service.data_processor.convert_dtos_to_dicts_list(products) + def webhook_product_insert( + self, credentials: APICredentials, catalog: Catalog, webhook_data, product_feed + ): + pvt_service = self.get_private_service( + credentials.app_key, credentials.app_token + ) + products_dto = pvt_service.update_webhook_product_info( + credentials.domain, webhook_data, catalog.vtex_app.config + ) + if not products_dto: + return None + + products_csv = pvt_service.data_processor.products_to_csv(products_dto) + self._update_products_on_facebook(products_csv, catalog, product_feed) + self.product_manager.update_products_on_database( + products_dto, catalog, product_feed + ) + return pvt_service.data_processor.convert_dtos_to_dicts_list(products_dto) + def _send_products_to_facebook(self, products_csv, catalog: Catalog): current_time = datetime.now().strftime("%Y-%m-%d_%H-%M") file_name = f"csv_vtex_products_{current_time}.csv" @@ -165,11 +157,25 @@ def _create_product_feed(self, name, catalog: Catalog) -> ProductFeed: ) return product_feed - def _upload_product_feed(self, product_feed_id, csv_file, file_name): + def _upload_product_feed( + self, product_feed_id, csv_file, file_name, update_only=False + ): response = self.fb_service.upload_product_feed( - product_feed_id, csv_file, file_name, "text/csv" + product_feed_id, csv_file, file_name, "text/csv", update_only ) if "id" not in response: raise FileNotSendValidationError() return True + + def _update_products_on_facebook( + self, products_csv, catalog: Catalog, product_feed + ) -> bool: + current_time = datetime.now().strftime("%Y-%m-%d_%H-%M") + file_name = f"update_{current_time}_{product_feed.name}" + return self._upload_product_feed( + product_feed.facebook_feed_id, + products_csv, + file_name, + update_only=True, + ) diff --git a/marketplace/services/vtex/private/products/service.py b/marketplace/services/vtex/private/products/service.py index cad61743..652878c9 100644 --- a/marketplace/services/vtex/private/products/service.py +++ b/marketplace/services/vtex/private/products/service.py @@ -76,11 +76,9 @@ def simulate_cart_for_seller(self, sku_id, seller_id, domain): sku_id, seller_id, domain ) # TODO: Change to pvt_simulate_cart_for_seller - def update_product_info( + def update_webhook_product_info( self, domain, webhook_payload, config ) -> List[FacebookProductDTO]: - updated_products = [] - sku_id = webhook_payload["IdSku"] price_modified = webhook_payload["PriceModified"] stock_modified = webhook_payload["StockModified"] @@ -94,11 +92,7 @@ def update_product_info( [sku_id], seller_ids, self, domain, rules, update_product=True ) - updated_products = DataProcessor.convert_dtos_to_dicts_list( - updated_products_dto - ) - - return updated_products + return updated_products_dto # ================================ # Private Methods diff --git a/marketplace/services/vtex/utils/data_processor.py b/marketplace/services/vtex/utils/data_processor.py index f675256a..e5e7dad4 100644 --- a/marketplace/services/vtex/utils/data_processor.py +++ b/marketplace/services/vtex/utils/data_processor.py @@ -45,10 +45,16 @@ def extract_fields(product_details, availability_details) -> FacebookProductDTO: if product_details.get("Images") else product_details.get("ImageUrl") ) + description = ( + product_details["ProductDescription"] + if product_details["ProductDescription"] != "" + else product_details["SkuName"] + ) + return FacebookProductDTO( id=product_details["Id"], title=product_details["SkuName"], - description=product_details["SkuName"], + description=description, availability="in stock" if availability_details["is_available"] else "out of stock", diff --git a/marketplace/webhooks/vtex/product_updates.py b/marketplace/webhooks/vtex/product_updates.py index 3baf869d..def4b019 100644 --- a/marketplace/webhooks/vtex/product_updates.py +++ b/marketplace/webhooks/vtex/product_updates.py @@ -3,36 +3,19 @@ from rest_framework import status from rest_framework.permissions import AllowAny -from marketplace.services.vtex.private.products.service import PrivateProductsService -from marketplace.clients.vtex.client import VtexPrivateClient from marketplace.applications.models import App from marketplace.services.vtex.exceptions import ( - CredentialsValidationError, NoVTEXAppConfiguredException, ) -from marketplace.clients.flows.client import FlowsClient -from marketplace.services.flows.service import FlowsService +from marketplace.celery import app as celery_app class VtexProductUpdateWebhook(APIView): - flows_client_class = FlowsClient - flows_service_class = FlowsService - - vtex_client_class = VtexPrivateClient - vtex_service_class = PrivateProductsService - authentication_classes = [] permission_classes = [AllowAny] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._flows_service = None - - @property - def flows_service(self): # pragma: no cover - if not self._flows_service: - self._flows_service = self.flows_service_class(self.flows_client_class()) - return self._flows_service def post(self, request, app_uuid): app = self.get_app(app_uuid) @@ -41,13 +24,11 @@ def post(self, request, app_uuid): {"error": "initial sync not completed"}, status=status.HTTP_400_BAD_REQUEST, ) - domain, app_key, app_token = self.get_credentials_or_raise(app) - vtex_service = self.get_vtex_service(app_key, app_token) - # TODO: Generate task for update by webhook - products_updated = vtex_service.update_product_info( - domain, request.data, app.config + + celery_app.send_task( + name="task_update_vtex_products", + kwargs={"webhook_data": request.data, "app_uuid": app_uuid}, ) - self.flows_service.update_webhook_vtex_products(products_updated, app) return Response(status=status.HTTP_200_OK) def get_app(self, app_uuid): @@ -58,16 +39,3 @@ def get_app(self, app_uuid): def can_synchronize(self, app): return app.config.get("initial_sync_completed", False) - - def get_credentials_or_raise(self, app): - # TODO: Move this to service - domain = app.config["api_credentials"]["domain"] - app_key = app.config["api_credentials"]["app_key"] - app_token = app.config["api_credentials"]["app_token"] - if not domain or not app_key or not app_token: - raise CredentialsValidationError() - return domain, app_key, app_token - - def get_vtex_service(self, app_key, app_token): - client = self.vtex_client_class(app_key, app_token) - return self.vtex_service_class(client) diff --git a/marketplace/webhooks/vtex/tests/test_product_updates.py b/marketplace/webhooks/vtex/tests/test_product_updates.py index 2018576c..d12022a2 100644 --- a/marketplace/webhooks/vtex/tests/test_product_updates.py +++ b/marketplace/webhooks/vtex/tests/test_product_updates.py @@ -9,19 +9,6 @@ from marketplace.applications.models import App -class MockVtexService: - def update_product_info(self, domain, webhook_payload, config): - return [{"id": 1, "sku": 1}, {"id": 2, "sku": 2}] - - -class MockFlowsService: - def update_vtex_products(self, products, flow_object_uuid, facebook_catalog_id): - return None - - def update_webhook_vtex_products(self, products, app): - return True - - class SetUpTestBase(APIBaseTestCase): view_class = VtexProductUpdateWebhook @@ -74,25 +61,10 @@ class MockServiceTestCase(SetUpTestBase): def setUp(self): super().setUp() - # Mock Vtex service - self.mock_vtex_service = MockVtexService() - patcher_vtex = patch.object( - self.view_class, - "vtex_service_class", - lambda *args, **kwargs: self.mock_vtex_service, - ) - self.addCleanup(patcher_vtex.stop) - patcher_vtex.start() - - # Mock Flows service - self.mock_flows_service = MockFlowsService() - patcher_flows = patch.object( - self.view_class, - "flows_service_class", - lambda *args, **kwargs: self.mock_flows_service, - ) - self.addCleanup(patcher_flows.stop) - patcher_flows.start() + # Mock Celery send_task + patcher_celery = patch("marketplace.celery.app.send_task") + self.mock_send_task = patcher_celery.start() + self.addCleanup(patcher_celery.stop) class WebhookTestCase(MockServiceTestCase): @@ -126,17 +98,3 @@ def test_webhook_with_app_not_found(self): url, {"data": "webhook_payload"}, app_uuid=app_uuid ) self.assertEqual(response.status_code, 404) - - def test_webhook_with_invalid_credentials(self): - self.app.config["initial_sync_completed"] = True - self.app.config["api_credentials"] = { - "domain": "", - "app_key": "", - "app_token": "", - } - self.app.save() - - response = self.request.post( - self.url, {"data": "webhook_payload"}, app_uuid=self.app.uuid - ) - self.assertEqual(response.status_code, 400) diff --git a/marketplace/wpp_products/tasks.py b/marketplace/wpp_products/tasks.py index 3a0d736d..8f66f596 100644 --- a/marketplace/wpp_products/tasks.py +++ b/marketplace/wpp_products/tasks.py @@ -153,3 +153,47 @@ def task_insert_vtex_products(**kwargs): logger.error( f"Error on insert vtex products for catalog {str(catalog.uuid)}, {e}" ) + + +@celery_app.task(name="task_update_vtex_products") +def task_update_vtex_products(**kwargs): + vtex_service = VtexService() + flows_service = FlowsService(FlowsClient()) + + app_uuid = kwargs.get("app_uuid") + webhook_data = kwargs.get("webhook_data") + vtex_app = App.objects.get(uuid=app_uuid, configured=True, code="vtex") + + try: + domain, app_key, app_token = vtex_service.get_vtex_credentials_or_raise( + vtex_app + ) + api_credentials = APICredentials( + app_key=app_key, + app_token=app_token, + domain=domain, + ) + for catalog in vtex_app.vtex_catalogs.all(): + if catalog.feeds.all().exists(): + product_feed = catalog.feeds.all().first() # The first feed created + products = vtex_service.webhook_product_insert( + api_credentials, catalog, webhook_data, product_feed + ) + if products is None: + logger.info( + f"No products to process after treatment for VTEX app {app_uuid}. Task ending." + ) + return + + dict_catalog = { + "name": catalog.name, + "facebook_catalog_id": catalog.facebook_catalog_id, + } + flows_service.update_vtex_products( + products, str(catalog.app.flow_object_uuid), dict_catalog + ) + print("Webhook Products updated and sent to flows successfully") + except Exception as e: + logger.error( + f"Error on updating Webhook vtex products for app {app_uuid}, {str(e)}" + )