diff --git a/docker/Dockerfile b/docker/Dockerfile index 237949b7..605e10c7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1 -ARG PYTHON_VERSION="3.8" +ARG PYTHON_VERSION="3.9" ARG DEBIAN_VERSION="buster" ARG POETRY_VERSION="1.7.0" 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/facebook/client.py b/marketplace/clients/facebook/client.py index 7558ef70..6e08ccd0 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: @@ -54,21 +54,26 @@ 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, 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, ) } - 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( + 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/clients/flows/client.py b/marketplace/clients/flows/client.py index 06f5cad0..5c70f02e 100644 --- a/marketplace/clients/flows/client.py +++ b/marketplace/clients/flows/client.py @@ -52,6 +52,17 @@ def update_config(self, data, flow_object_uuid): ) return response + 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=url, + method=action, + headers=self.authentication_instance.headers, + 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/" @@ -90,3 +101,18 @@ def update_facebook_templates(self, flow_object_uuid, fba_templates): json=data, ) return response + + def update_vtex_products(self, products, flow_object_uuid, dict_catalog): + data = { + "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", + headers=self.authentication_instance.headers, + json=data, + ) + return response diff --git a/marketplace/clients/vtex/client.py b/marketplace/clients/vtex/client.py new file mode 100644 index 00000000..c1bb926f --- /dev/null +++ b/marketplace/clients/vtex/client.py @@ -0,0 +1,100 @@ +from marketplace.clients.base import RequestClient + + +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/" + response = self.make_request(url, method="GET") + 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 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 = 1 + + while True: + 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) + + sku_ids = response.json() + if not sku_ids: + break + + all_skus.extend(sku_ids) + page += 1 + + return all_skus + + 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/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/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/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py b/marketplace/core/types/channels/whatsapp_cloud/catalogs/tests/test_catalog_views.py index 19771e4d..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 @@ -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 True, {"success": "True"} @@ -69,6 +81,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 ) @@ -82,6 +106,10 @@ def view(self): class MockServiceTestCase(SetUpTestBase): def setUp(self): super().setUp() + # 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() @@ -108,13 +136,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) @@ -130,7 +158,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( @@ -216,9 +244,128 @@ 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.", + ) + + 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/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 d7cbd4fc..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 ( @@ -23,6 +23,8 @@ TresholdSerializer, CatalogListSerializer, ) +from marketplace.services.vtex.generic_service import VtexService +from marketplace.celery import app as celery_app class BaseViewSet(viewsets.ModelViewSet): @@ -59,6 +61,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 @@ -78,6 +91,35 @@ 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.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 + ) + if not catalog: + return Response( + {"detail": "Failed to create catalog on Facebook."}, + 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): catalog = self.get_object() connected_catalog_id = self.fb_service.get_connected_catalog(catalog.app) @@ -102,6 +144,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): 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 deleted file mode 100644 index e09f853c..00000000 --- a/marketplace/core/types/channels/whatsapp_cloud/services/facebook.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -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: - get_app_facebook_credentials(app): Retrieves the Facebook credentials from the app's configuration. - - enable_catalog(catalog): Enables a catalog for a given app. - - disable_catalog(catalog): Disables a catalog for a given app. - - get_connected_catalog(app): Gets the currently connected catalog ID for a given app. - - toggle_cart(app, enable=True): Toggles the cart setting for WhatsApp Business. - - toggle_catalog_visibility(app, visible=True): Toggles the visibility of the catalog on WhatsApp Business. - - wpp_commerce_settings(app): Retrieves the WhatsApp commerce settings associated with a business phone number. - -Raises: - ValueError: If required Facebook credentials are missing from the app's configuration. -""" - - -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") - - 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 enable_catalog(self, catalog): - waba_id = self.get_app_facebook_credentials(app=catalog.app).get("wa_waba_id") - 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") - 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") - response = self.client.get_connected_catalog(waba_id=waba_id) - - if len(response.get("data")) > 0: - return response.get("data")[0].get("id") - - return [] - - def toggle_cart(self, app, enable=True): - 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( - "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( - "wa_phone_number_id" - ) - return self.client.get_wpp_commerce_settings(business_phone_number_id) diff --git a/marketplace/core/types/ecommerce/__init__.py b/marketplace/core/types/ecommerce/__init__.py new file mode 100644 index 00000000..e69de29b 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/ecommerce/vtex/__init__.py b/marketplace/core/types/ecommerce/vtex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/core/types/ecommerce/vtex/serializers.py b/marketplace/core/types/ecommerce/vtex/serializers.py new file mode 100644 index 00000000..f31d6cdd --- /dev/null +++ b/marketplace/core/types/ecommerce/vtex/serializers.py @@ -0,0 +1,51 @@ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from marketplace.core.serializers import AppTypeBaseSerializer +from marketplace.applications.models import App + + +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): + config = serializers.SerializerMethodField() + + class Meta: + model = App + fields = ( + "code", + "uuid", + "project_uuid", + "platform", + "config", + "created_by", + "created_on", + "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/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/ecommerce/vtex/tests/test_views.py b/marketplace/core/types/ecommerce/vtex/tests/test_views.py new file mode 100644 index 00000000..68b68a3d --- /dev/null +++ b/marketplace/core/types/ecommerce/vtex/tests/test_views.py @@ -0,0 +1,218 @@ +import uuid + +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 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() + + +class MockVtexService: + def check_is_valid_credentials(self, credentials): + return 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 + + +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 vtex 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() + + # 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") + + def setUp(self): + super().setUp() + 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": 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=self.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) + + @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) + + 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_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_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")) + + 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 + + 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 DeleteVtexAppTestCase(SetUpService): + 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_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/ecommerce/vtex/type.py b/marketplace/core/types/ecommerce/vtex/type.py new file mode 100644 index 00000000..c1a6417a --- /dev/null +++ b/marketplace/core/types/ecommerce/vtex/type.py @@ -0,0 +1,14 @@ +from ..base import EcommerceAppType +from .views import VtexViewSet + + +class VtexType(EcommerceAppType): + 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 = "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..e87a6911 --- /dev/null +++ b/marketplace/core/types/ecommerce/vtex/views.py @@ -0,0 +1,78 @@ +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 +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 + if not self._service: + self._service = self.service_class() + + 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) + + 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"), + ) + 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, wpp_cloud_uuid) + 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, + ) + + 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) + 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/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/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/__init__.py b/marketplace/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/marketplace/services/facebook/service.py b/marketplace/services/facebook/service.py new file mode 100644 index 00000000..8ca22d27 --- /dev/null +++ b/marketplace/services/facebook/service.py @@ -0,0 +1,111 @@ +from marketplace.wpp_products.models import Catalog + + +class FacebookService: + def __init__(self, client): + self.client = client + + # ================================ + # Public Methods + # ================================ + + 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") + 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") + 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") + response = self.client.get_connected_catalog(waba_id=waba_id) + + if len(response.get("data")) > 0: + return response.get("data")[0].get("id") + + return [] + + def toggle_cart(self, app, enable=True): + 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( + "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( + "wa_phone_number_id" + ) + 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 + # ================================ + + 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/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 61% 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 f9559c7e..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 @@ -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): @@ -129,7 +143,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", @@ -138,3 +152,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/services/flows/service.py b/marketplace/services/flows/service.py new file mode 100644 index 00000000..b3f272e5 --- /dev/null +++ b/marketplace/services/flows/service.py @@ -0,0 +1,21 @@ +class FlowsService: + def __init__(self, client): + self.client = client + + 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, flow_object_uuid, dict_catalog): + return self.client.update_vtex_products( + products, flow_object_uuid, dict_catalog + ) + + 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..4449c55d --- /dev/null +++ b/marketplace/services/product/product_facebook_manage.py @@ -0,0 +1,99 @@ +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 + ): + 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) + + 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/__init__.py b/marketplace/services/vtex/__init__.py new file mode 100644 index 00000000..e69de29b 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/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/exceptions.py b/marketplace/services/vtex/exceptions.py new file mode 100644 index 00000000..57cad9fa --- /dev/null +++ b/marketplace/services/vtex/exceptions.py @@ -0,0 +1,32 @@ +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" + + +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 new file mode 100644 index 00000000..eb31d9b7 --- /dev/null +++ b/marketplace/services/vtex/generic_service.py @@ -0,0 +1,181 @@ +""" +Service for managing VTEX App instances within a project. +""" +from datetime import datetime + +from dataclasses import dataclass + +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, +) +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 +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: + 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 + ) -> PrivateProductsService: # pragma nocover + if not self._pvt_service: + client = VtexPrivateClient(app_key, app_token) + self._pvt_service = PrivateProductsService(client) + return self._pvt_service + + def check_is_valid_credentials(self, credentials: APICredentials) -> bool: + 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, 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 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 + ) + 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 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" + 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, update_only=False + ): + response = self.fb_service.upload_product_feed( + 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/__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..652878c9 --- /dev/null +++ b/marketplace/services/vtex/private/products/service.py @@ -0,0 +1,112 @@ +""" +Service for managing product operations with VTEX private APIs. + +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 client instance for VTEX private APIs communication. + data_processor: DataProcessor instance for processing product data. + +Public Methods: + 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 for invalid domain or credentials. + +Usage: + Instantiate with a client having API credentials. Use methods for product operations with VTEX. + +Example: + client = VtexPrivateClient(app_key="key", app_token="token") + service = PrivateProductsService(client) + is_valid = service.validate_private_credentials("domain.vtex.com") + if is_valid: + 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: + 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 + # ================================ + + 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) + + 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", [])) + products_dto = self.data_processor.process_product_data( + skus_ids, active_sellers, self, domain, rules + ) + return products_dto + + 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_webhook_product_info( + self, domain, webhook_payload, config + ) -> List[FacebookProductDTO]: + 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: + rules = self._load_rules(config.get("rules", [])) + updated_products_dto = self.data_processor.process_product_data( + [sku_id], seller_ids, self, domain, rules, update_product=True + ) + + return updated_products_dto + + # ================================ + # Private Methods + # ================================ + + 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/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/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/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/__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..0acb7923 --- /dev/null +++ b/marketplace/services/vtex/public/products/tests/test_products_vtex_service.py @@ -0,0 +1,70 @@ +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.service import ( + PublicProductsService, +) +from marketplace.services.vtex.exceptions import CredentialsValidationError + +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 = PublicProductsService(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.check_is_valid_domain("valid.domain.com") + self.assertTrue(response) + + 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(CredentialsValidationError) as context: + self.service.list_all_products("invalid.domain.com") + self.assertTrue( + "The credentials provided are invalid." in str(context.exception) + ) + + def test_is_domain_invalid_domain(self): + 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/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..b79767fc --- /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.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.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.app_manager.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) 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..e5e7dad4 --- /dev/null +++ b/marketplace/services/vtex/utils/data_processor.py @@ -0,0 +1,112 @@ +import pandas as pd +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 + + +@dataclass +class VtexProductDTO: # TODO: Implement This VtexProductDTO + pass + + +class DataProcessor: + @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 + ) + image_url = ( + product_details.get("Images", [])[0].get("ImageUrl") + 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=description, + availability="in stock" + if availability_details["is_available"] + else "out of stock", + condition="new", + price=list_price, + 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, + ) + + @staticmethod + def process_product_data( + 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 + + product_dto = DataProcessor.extract_fields( + product_details, availability_details + ) + 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 + + 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 + + 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) + + return dicts_list diff --git a/marketplace/settings.py b/marketplace/settings.py index edf54677..4a4b0f98 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", @@ -282,6 +283,7 @@ # Externals APPTYPE_OMIE_PATH = "externals.omie.type.OmieType" APPTYPE_CHATGPT_PATH = "externals.chatgpt.type.ChatGPTType" +APPTYPE_VTEX_PATH = "ecommerce.vtex.type.VtexType" APPTYPES_CLASSES = [ APPTYPE_WENI_WEB_CHAT_PATH, @@ -294,6 +296,7 @@ APPTYPE_OMIE_PATH, APPTYPE_GENERIC_CHANNEL_PATH, APPTYPE_CHATGPT_PATH, + APPTYPE_VTEX_PATH, ] # These conditions avoid dependence between apptypes, 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..def4b019 --- /dev/null +++ b/marketplace/webhooks/vtex/product_updates.py @@ -0,0 +1,41 @@ +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.applications.models import App +from marketplace.services.vtex.exceptions import ( + NoVTEXAppConfiguredException, +) +from marketplace.celery import app as celery_app + + +class VtexProductUpdateWebhook(APIView): + authentication_classes = [] + permission_classes = [AllowAny] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + 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, + ) + + celery_app.send_task( + name="task_update_vtex_products", + kwargs={"webhook_data": request.data, "app_uuid": app_uuid}, + ) + 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) 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..d12022a2 --- /dev/null +++ b/marketplace/webhooks/vtex/tests/test_product_updates.py @@ -0,0 +1,100 @@ +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 SetUpTestBase(APIBaseTestCase): + view_class = VtexProductUpdateWebhook + + def setUp(self): + super().setUp() + api_credentials = { + "domain": "valid_domain", + "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", + 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 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): + 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) 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", + ), +] 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/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 6f4fc369..d43b38fd 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, @@ -52,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/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: diff --git a/marketplace/wpp_products/tasks.py b/marketplace/wpp_products/tasks.py index 2053a2a6..8f66f596 100644 --- a/marketplace/wpp_products/tasks.py +++ b/marketplace/wpp_products/tasks.py @@ -8,6 +8,10 @@ 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__) @@ -119,3 +123,77 @@ def create_catalog_task(app, details): ) 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) + 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("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}" + ) + + +@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)}" + ) 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"