diff --git a/poetry.lock b/poetry.lock index e069cb8..16ec0eb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "amqp" @@ -31,6 +31,28 @@ typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "billiard" +version = "4.2.1" +description = "Python multiprocessing fork with improvements and bugfixes" +optional = false +python-versions = ">=3.7" +files = [ + {file = "billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb"}, + {file = "billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f"}, +] + [[package]] name = "black" version = "24.8.0" @@ -77,6 +99,62 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "celery" +version = "5.4.0" +description = "Distributed Task Queue." +optional = false +python-versions = ">=3.8" +files = [ + {file = "celery-5.4.0-py3-none-any.whl", hash = "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64"}, + {file = "celery-5.4.0.tar.gz", hash = "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706"}, +] + +[package.dependencies] +billiard = ">=4.2.0,<5.0" +click = ">=8.1.2,<9.0" +click-didyoumean = ">=0.3.0" +click-plugins = ">=1.1.1" +click-repl = ">=0.2.0" +kombu = ">=5.3.4,<6.0" +python-dateutil = ">=2.8.2" +tzdata = ">=2022.7" +vine = ">=5.1.0,<6.0" + +[package.extras] +arangodb = ["pyArango (>=2.0.2)"] +auth = ["cryptography (==42.0.5)"] +azureblockblob = ["azure-storage-blob (>=12.15.0)"] +brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] +cassandra = ["cassandra-driver (>=3.25.0,<4)"] +consul = ["python-consul2 (==0.1.5)"] +cosmosdbsql = ["pydocumentdb (==2.3.5)"] +couchbase = ["couchbase (>=3.0.0)"] +couchdb = ["pycouchdb (==1.14.2)"] +django = ["Django (>=2.2.28)"] +dynamodb = ["boto3 (>=1.26.143)"] +elasticsearch = ["elastic-transport (<=8.13.0)", "elasticsearch (<=8.13.0)"] +eventlet = ["eventlet (>=0.32.0)"] +gcs = ["google-cloud-storage (>=2.10.0)"] +gevent = ["gevent (>=1.5.0)"] +librabbitmq = ["librabbitmq (>=2.0.0)"] +memcache = ["pylibmc (==1.6.3)"] +mongodb = ["pymongo[srv] (>=4.0.2)"] +msgpack = ["msgpack (==1.0.8)"] +pymemcache = ["python-memcached (>=1.61)"] +pyro = ["pyro4 (==4.82)"] +pytest = ["pytest-celery[all] (>=1.0.0)"] +redis = ["redis (>=4.5.2,!=4.5.5,<6.0.0)"] +s3 = ["boto3 (>=1.26.143)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +solar = ["ephem (==4.1.5)"] +sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] +sqs = ["boto3 (>=1.26.143)", "kombu[sqs] (>=5.3.4)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] +tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] +zstd = ["zstandard (==0.22.0)"] + [[package]] name = "certifi" version = "2024.8.30" @@ -280,6 +358,55 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-didyoumean" +version = "0.3.1" +description = "Enables git-like *did-you-mean* feature in click" +optional = false +python-versions = ">=3.6.2" +files = [ + {file = "click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c"}, + {file = "click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463"}, +] + +[package.dependencies] +click = ">=7" + +[[package]] +name = "click-plugins" +version = "1.1.1" +description = "An extension module for click to enable registering CLI commands via setuptools entry-points." +optional = false +python-versions = "*" +files = [ + {file = "click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b"}, + {file = "click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8"}, +] + +[package.dependencies] +click = ">=4.0" + +[package.extras] +dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"] + +[[package]] +name = "click-repl" +version = "0.3.0" +description = "REPL plugin for Click" +optional = false +python-versions = ">=3.6" +files = [ + {file = "click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9"}, + {file = "click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812"}, +] + +[package.dependencies] +click = ">=7.0" +prompt-toolkit = ">=3.0.36" + +[package.extras] +testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"] + [[package]] name = "colorama" version = "0.4.6" @@ -527,6 +654,39 @@ pyopenssl = ">=0.13" [package.extras] docs = ["sphinx (>=4.3.0)", "sphinx-rtd-theme (>=1.0)"] +[[package]] +name = "kombu" +version = "5.4.2" +description = "Messaging library for Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "kombu-5.4.2-py3-none-any.whl", hash = "sha256:14212f5ccf022fc0a70453bb025a1dcc32782a588c49ea866884047d66e14763"}, + {file = "kombu-5.4.2.tar.gz", hash = "sha256:eef572dd2fd9fc614b37580e3caeafdd5af46c1eff31e7fba89138cdb406f2cf"}, +] + +[package.dependencies] +amqp = ">=5.1.1,<6.0.0" +tzdata = {version = "*", markers = "python_version >= \"3.9\""} +vine = "5.1.0" + +[package.extras] +azureservicebus = ["azure-servicebus (>=7.10.0)"] +azurestoragequeues = ["azure-identity (>=1.12.0)", "azure-storage-queue (>=12.6.0)"] +confluentkafka = ["confluent-kafka (>=2.2.0)"] +consul = ["python-consul2 (==0.1.5)"] +librabbitmq = ["librabbitmq (>=2.0.0)"] +mongodb = ["pymongo (>=4.1.1)"] +msgpack = ["msgpack (==1.1.0)"] +pyro = ["pyro4 (==4.82)"] +qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] +redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"] +sqs = ["boto3 (>=1.26.143)", "pycurl (>=7.43.0.5)", "urllib3 (>=1.26.16)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=2.8.0)"] + [[package]] name = "mccabe" version = "0.7.0" @@ -604,6 +764,20 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.11.2)"] +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, + {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, +] + +[package.dependencies] +wcwidth = "*" + [[package]] name = "psycopg2" version = "2.9.9" @@ -758,6 +932,20 @@ cryptography = ">=41.0.5,<44" docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"] test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pytz" version = "2024.2" @@ -833,17 +1021,21 @@ files = [ [[package]] name = "redis" -version = "3.5.3" -description = "Python client for Redis key-value store" +version = "5.2.0" +description = "Python client for Redis database and key-value store" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.8" files = [ - {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, - {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"}, + {file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"}, + {file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"}, ] +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + [package.extras] -hiredis = ["hiredis (>=0.1.3)"] +hiredis = ["hiredis (>=3.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] [[package]] name = "requests" @@ -866,6 +1058,17 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sqlparse" version = "0.5.1" @@ -953,6 +1156,17 @@ files = [ {file = "vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0"}, ] +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "weni-eda" version = "0.1.1" @@ -970,4 +1184,4 @@ amqp = ">=5.2.0,<6.0.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "33bda24aecc3cf9907b4e6fc25f6792288ea7a253a79ab37e43cff2e2153c162" +content-hash = "29f0b4ccf36ce0c81c1fa689d8e6e1e96d793b05713d6dc7e3d617ae89d76b91" diff --git a/pyproject.toml b/pyproject.toml index 53d17fb..8ae9d6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,6 @@ name = "retail-setup" version = "0.0.1" description = "" authors = ["Weni"] -package-mode = false [tool.poetry.dependencies] python = "^3.10" @@ -17,7 +16,7 @@ mozilla-django-oidc = "^4.0.1" djangorestframework = "^3.15.2" drf-yasg = "^1.21.7" django-cors-headers = "^4.4.0" -redis = "^3.5.3" +celery = "^5.1.2" django-redis = "^5.2.0" [tool.poetry.group.dev.dependencies] diff --git a/retail/api/features/views.py b/retail/api/features/views.py index 3240342..2058597 100644 --- a/retail/api/features/views.py +++ b/retail/api/features/views.py @@ -13,6 +13,7 @@ class FeaturesView(BaseServiceView): def get(self, request, project_uuid: str): try: category = request.query_params.get("category", None) + can_vtex_integrate = request.query_params.get("can_vtex_integrate", None) integrated_features = IntegratedFeature.objects.filter( project__uuid=project_uuid @@ -26,11 +27,18 @@ def get(self, request, project_uuid: str): for email in settings.EMAILS_CAN_TESTING: if email in request.user.email: can_testing = True + if not can_testing: features = features.exclude(status="testing") + if category: features = features.filter(category=category) + if can_vtex_integrate: + # Convert "true"/"false" to boolean + can_vtex_integrate = can_vtex_integrate == 'true' + features = features.filter(can_vtex_integrate=can_vtex_integrate) + serializer = FeaturesSerializer(features, many=True) usecase = RemoveGlobalsKeysUsecase( diff --git a/retail/api/integrated_feature/views.py b/retail/api/integrated_feature/views.py index cb5f0a0..2085c2c 100644 --- a/retail/api/integrated_feature/views.py +++ b/retail/api/integrated_feature/views.py @@ -5,8 +5,12 @@ from retail.api.base_service_view import BaseServiceView from retail.api.integrated_feature.serializers import IntegratedFeatureSerializer +from retail.api.usecases.create_integrated_feature_usecase import ( + CreateIntegratedFeatureUseCase, +) from retail.api.usecases.populate_globals_values import PopulateGlobalsValuesUsecase +from retail.api.usecases.populate_globals_with_defaults import PopulateDefaultsUseCase from retail.features.models import Feature, IntegratedFeature from retail.features.integrated_feature_eda import IntegratedFeatureEDA from retail.projects.models import Project @@ -14,122 +18,17 @@ class IntegratedFeatureView(BaseServiceView): def post(self, request, *args, **kwargs): - feature = Feature.objects.get(uuid=kwargs["feature_uuid"]) - try: - project = Project.objects.get(uuid=request.data["project_uuid"]) - except Project.DoesNotExist: - return Response( - status=status.HTTP_404_NOT_FOUND, - data={ - "error": f"Project with uuid equals {request.data['project_uuid']} does not exist!" - }, - ) - user, _ = User.objects.get_or_create(email=request.user.email) - feature_version = feature.last_version + request_data = request.data.copy() + request_data["feature_uuid"] = kwargs.get("feature_uuid") - integrated_feature = IntegratedFeature.objects.create( - project=project, feature=feature, feature_version=feature_version, user=user + use_case = CreateIntegratedFeatureUseCase( + integrations_service=self.integrations_service, + flows_service=self.flows_service, ) - sectors_data = [] - integrated_feature.sectors = [] - if feature_version.sectors is not None: - for sector in feature_version.sectors: - for r_sector in request.data.get("sectors", []): - if r_sector.get("name") == sector.get("name"): - new_sector = { - "name": r_sector.get("name"), - "tags": r_sector.get("tags"), - "queues": sector.get("queues"), - } - integrated_feature.sectors.append(new_sector) - break - - # Treat and fill specific globals - fill_globals_usecase = PopulateGlobalsValuesUsecase( - self.integrations_service, self.flows_service - ) - globals_values_request = {} - for globals_values in feature_version.globals_values: - globals_values_request[globals_values] = "" - - for key, value in request.data.get("globals_values", {}).items(): - globals_values_request[key] = value - - treated_globals_values = fill_globals_usecase.execute( - globals_values_request, - request.user.email, - request.data["project_uuid"], - ) - - # Add all globals from the request, including treated ones - for globals_key, globals_value in treated_globals_values.items(): - integrated_feature.globals_values[globals_key] = globals_value - - for sector in integrated_feature.sectors: - sectors_data.append( - { - "name": sector.get("name", ""), - "tags": sector.get("tags", ""), - "service_limit": 4, - "working_hours": {"init": "08:00", "close": "18:00"}, - "queues": sector.get("queues", []), - } - ) - - actions = [] - for function in feature.functions.all(): - function_last_version = function.last_version - if function_last_version.action_base_flow_uuid is not None: - actions.append( - { - "name": function_last_version.action_name, - "prompt": function_last_version.action_prompt, - "root_flow_uuid": str( - function_last_version.action_base_flow_uuid - ), - "type": "", - } - ) - if feature_version.action_base_flow_uuid: - actions.append( - { - "name": feature_version.action_name, - "prompt": feature_version.action_prompt, - "root_flow_uuid": str(feature_version.action_base_flow_uuid), - "type": "", - } - ) - - body = { - "definition": integrated_feature.feature_version.definition, - "user_email": integrated_feature.user.email, - "project_uuid": str(integrated_feature.project.uuid), - "parameters": integrated_feature.globals_values, - "feature_version": str(integrated_feature.feature_version.uuid), - "feature_uuid": str(integrated_feature.feature.uuid), - "sectors": sectors_data, - "action": actions, - } - - IntegratedFeatureEDA().publisher(body=body, exchange="integrated-feature.topic") - print(f"message sent `integrated feature` - body: {body}") - - serializer = IntegratedFeatureSerializer(integrated_feature.feature) - - response = { - "status": 200, - "data": { - "feature": integrated_feature.feature.uuid, - "feature_version": integrated_feature.feature_version.uuid, - "project": integrated_feature.project.uuid, - "user": integrated_feature.user.email, - "integrated_on": integrated_feature.integrated_on, - **serializer.data, - }, - } - return Response(response) + response_data = use_case.execute(request_data, user) + return Response(response_data, status=status.HTTP_200_OK) def get(self, request, project_uuid): try: diff --git a/retail/api/usecases/create_integrated_feature_usecase.py b/retail/api/usecases/create_integrated_feature_usecase.py new file mode 100644 index 0000000..5d00484 --- /dev/null +++ b/retail/api/usecases/create_integrated_feature_usecase.py @@ -0,0 +1,256 @@ +from rest_framework.exceptions import ValidationError, NotFound +from django.conf import settings + +from retail.api.integrated_feature.serializers import IntegratedFeatureSerializer +from retail.api.usecases.populate_globals_values import PopulateGlobalsValuesUsecase +from retail.api.usecases.populate_globals_with_defaults import PopulateDefaultsUseCase + +from retail.features.integrated_feature_eda import IntegratedFeatureEDA + +from retail.features.models import Feature, IntegratedFeature +from retail.projects.models import Project + + +class CreateIntegratedFeatureUseCase: + """ + Use case to handle the creation and configuration of an IntegratedFeature. + """ + + def __init__(self, integrations_service, flows_service): + self.integrations_service = integrations_service + self.flows_service = flows_service + + def execute(self, request_data, user): + """ + Execute the use case to create and configure an IntegratedFeature. + + Args: + request_data (dict): Data from the request. + user (User): The user performing the action. + + Returns: + dict: Response data including integration details. + + Raises: + ValidationError: If the feature is already integrated with the project. + NotFound: If the feature or project does not exist. + """ + # Validation and object retrieval + feature = self._get_feature(request_data["feature_uuid"]) + project = self._get_project(request_data["project_uuid"]) + + # Check if the feature is already integrated with the project + if self._is_feature_already_integrated(feature, project): + raise ValidationError( + f"Feature '{feature.uuid}' is already integrated with project '{project.uuid}'." + ) + + # Create IntegratedFeature + integrated_feature = self._create_integrated_feature( + feature, project, user, request_data.get("created_by_vtex", False) + ) + + # Process sectors and globals + self._process_sectors( + integrated_feature, feature, request_data.get("sectors", []) + ) + self._process_globals( + integrated_feature, feature, request_data.get("globals_values", {}) + ) + + # Publish integration event + self._publish_integration_event(integrated_feature) + + # Prepare and return the response data + response_data = self._prepare_response_data(integrated_feature) + + return response_data + + def _get_feature(self, feature_uuid): + try: + return Feature.objects.get(uuid=feature_uuid) + except Feature.DoesNotExist: + raise NotFound(f"Feature with uuid '{feature_uuid}' does not exist.") + + def _get_project(self, project_uuid): + try: + return Project.objects.get(uuid=project_uuid) + except Project.DoesNotExist: + raise NotFound(f"Project with uuid '{project_uuid}' does not exist.") + + def _is_feature_already_integrated(self, feature, project): + """ + Check if the feature is already integrated with the given project. + + Args: + feature (Feature): The feature to check. + project (Project): The project to check. + + Returns: + bool: True if the feature is already integrated with the project, False otherwise. + """ + return IntegratedFeature.objects.filter( + feature=feature, project=project + ).exists() + + def _create_integrated_feature(self, feature, project, user, created_by_vtex): + feature_version = feature.last_version + integrated_feature = IntegratedFeature.objects.create( + project=project, + feature=feature, + feature_version=feature_version, + user=user, + created_by_vtex=created_by_vtex, + ) + return integrated_feature + + def _process_sectors(self, integrated_feature, feature, sectors_request): + """ + Process and set the sectors for the integrated feature. + + Args: + integrated_feature (IntegratedFeature): The integrated feature instance. + feature (Feature): The feature being integrated. + sectors_request (list): List of sectors provided in the request. + """ + integrated_feature.sectors = [] + if feature.last_version.sectors: + for sector in feature.last_version.sectors: + matching_sector = next( + (s for s in sectors_request if s.get("name") == sector.get("name")), + None, + ) + if matching_sector: + new_sector = { + "name": matching_sector.get("name"), + "tags": matching_sector.get("tags"), + "queues": sector.get("queues"), + } + integrated_feature.sectors.append(new_sector) + integrated_feature.save() + + def _process_globals(self, integrated_feature, feature, globals_values_request): + """ + Process and populate the global variables for the integrated feature. + + Args: + integrated_feature (IntegratedFeature): The integrated feature instance. + feature (Feature): The feature being integrated. + globals_values_request (dict): Global values provided in the request. + """ + # Initialize full_globals_values with all globals from feature_version, setting them to empty strings + feature_version = feature.last_version + full_globals_values = { + global_var: "" for global_var in feature_version.globals_values + } + + # Update with any provided values from the request + full_globals_values.update(globals_values_request) + + # Treat and fill specific globals + fill_globals_usecase = PopulateGlobalsValuesUsecase( + self.integrations_service, self.flows_service + ) + treated_globals_values = fill_globals_usecase.execute( + full_globals_values, + integrated_feature.user.email, + str(integrated_feature.project.uuid), + ) + + # If created by VTEX, populate default globals from config + if integrated_feature.created_by_vtex: + populate_defaults_use_case = PopulateDefaultsUseCase() + default_globals_values = populate_defaults_use_case.execute( + feature, full_globals_values + ) + # Merge default globals into treated_globals_values + treated_globals_values.update(default_globals_values) + + # Ensure all globals are included + integrated_feature.globals_values = treated_globals_values + integrated_feature.save() + + def _publish_integration_event(self, integrated_feature): + """ + Publish the integration event to the message broker. + + Args: + integrated_feature (IntegratedFeature): The integrated feature instance. + """ + # Prepare data for publishing + sectors_data = [ + { + "name": sector.get("name", ""), + "tags": sector.get("tags", ""), + "service_limit": 4, + "working_hours": {"init": "08:00", "close": "18:00"}, + "queues": sector.get("queues", []), + } + for sector in integrated_feature.sectors + ] + + actions = [] + feature_version = integrated_feature.feature_version + for function in integrated_feature.feature.functions.all(): + function_last_version = function.last_version + if function_last_version.action_base_flow_uuid: + actions.append( + { + "name": function_last_version.action_name, + "prompt": function_last_version.action_prompt, + "root_flow_uuid": str( + function_last_version.action_base_flow_uuid + ), + "type": "", + } + ) + + if feature_version.action_base_flow_uuid: + actions.append( + { + "name": feature_version.action_name, + "prompt": feature_version.action_prompt, + "root_flow_uuid": str(feature_version.action_base_flow_uuid), + "type": "", + } + ) + + body = { + "definition": feature_version.definition, + "user_email": integrated_feature.user.email, + "project_uuid": str(integrated_feature.project.uuid), + "parameters": integrated_feature.globals_values, + "feature_version": str(feature_version.uuid), + "feature_uuid": str(feature_version.feature.uuid), + "sectors": sectors_data, + "action": actions, + } + + IntegratedFeatureEDA().publisher(body=body, exchange="integrated-feature.topic") + print(f"message sent `integrated feature` - body: {body}") + + def _prepare_response_data(self, integrated_feature): + """ + Prepare the response data to be sent back to the client. + + Args: + integrated_feature (IntegratedFeature): The integrated feature instance. + + Returns: + dict: Response data including additional info if necessary. + """ + serializer = IntegratedFeatureSerializer(integrated_feature.feature) + response_data = { + "status": 200, + "data": { + "feature": str(integrated_feature.feature.uuid), + "integrated_feature": str(integrated_feature.uuid), + "feature_version": str(integrated_feature.feature_version.uuid), + "project": str(integrated_feature.project.uuid), + "user": integrated_feature.user.email, + "integrated_on": integrated_feature.integrated_on.isoformat(), + **serializer.data, + }, + } + + return response_data diff --git a/retail/api/usecases/populate_globals_with_defaults.py b/retail/api/usecases/populate_globals_with_defaults.py new file mode 100644 index 0000000..be66879 --- /dev/null +++ b/retail/api/usecases/populate_globals_with_defaults.py @@ -0,0 +1,58 @@ +from retail.features.models import Feature +from typing import Dict, Any + + +class PopulateDefaultsUseCase: + """ + Use case to populate default values from a feature's configuration. + This allows for setting default global values and potentially other configurations + as specified within the feature's config field. + """ + + def execute( + self, feature: Feature, globals_values: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Executes the population of default values for globals, updating + only with keys that have defined default values in the feature config. + + Args: + feature (Feature): The feature object containing configuration. + globals_values (dict): Current global values to be populated with defaults. + + Returns: + dict: Updated globals_values containing defaults for applicable keys. + """ + globals_values = self._populate_globals(feature, globals_values) + return globals_values + + def _populate_globals( + self, feature: Feature, globals_values: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Populates globals with default values from the feature config, + only for keys present in default settings. + + Args: + feature (Feature): The feature object containing configuration. + globals_values (dict): Current global values. + + Returns: + dict: Dictionary with globals populated only with default keys found in feature config. + """ + # Load default values for 'globals' from the feature's `config` field + default_globals = ( + feature.config.get("vtex_config", {}) + .get("default_params", {}) + .get("globals_values", {}) + ) + + # Create a dictionary with only the keys present in `default_globals` + populated_globals = { + key: default_value + for key, default_value in default_globals.items() + if key in globals_values + or globals_values.setdefault(key, default_value) is not None + } + + return populated_globals diff --git a/retail/api/usecases/remove_globals_keys.py b/retail/api/usecases/remove_globals_keys.py index 5190d0f..e899ef6 100644 --- a/retail/api/usecases/remove_globals_keys.py +++ b/retail/api/usecases/remove_globals_keys.py @@ -42,9 +42,7 @@ def execute( # Check and mark globals for removal based on flows data if available if flows_data: - if "api_token" in feature["globals"] and flows_data.get( - "api_token" - ): + if "api_token" in feature["globals"] and flows_data.get("api_token"): globals_to_remove.append("api_token") # Remove the marked globals diff --git a/retail/celery.py b/retail/celery.py new file mode 100644 index 0000000..585565c --- /dev/null +++ b/retail/celery.py @@ -0,0 +1,11 @@ +from __future__ import absolute_import + +import os +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "retail.settings") + +app = Celery("retail") + +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/retail/clients/flows/client.py b/retail/clients/flows/client.py index d1f8060..32b473a 100644 --- a/retail/clients/flows/client.py +++ b/retail/clients/flows/client.py @@ -12,6 +12,16 @@ def __init__(self): self.authentication_instance = InternalAuthentication() def get_user_api_token(self, user_email: str, project_uuid: str): + """ + Fetch a user API token from the Flows service. + + Args: + user_email (str): Email of the user. + project_uuid (str): UUID of the project. + + Returns: + str: API token for the user. + """ url = f"{self.base_url}/api/v2/internals/users/api-token/" params = dict(user=user_email, project=str(project_uuid)) response = self.make_request( @@ -21,3 +31,31 @@ def get_user_api_token(self, user_email: str, project_uuid: str): headers=self.authentication_instance.headers, ) return response.json() + + def send_whatsapp_broadcast(self, payload: dict, token: str) -> dict: + """ + Sends a WhatsApp broadcast message using the Flows API. + + Args: + payload (dict): The full body of the request as a pre-built payload. + token (str): Authorization token for the API. + + Returns: + dict: Response from the API. + """ + if not token: + raise ValueError("Authorization token is required to send a broadcast.") + + url = f"{self.base_url}/api/v2/whatsapp_broadcasts.json" + headers = { + "Authorization": f"Token {token}", + "Content-Type": "application/json", + } + + response = self.make_request( + url, + method="POST", + json=payload, + headers=headers, + ) + return response.json() diff --git a/retail/clients/integrations/client.py b/retail/clients/integrations/client.py index 1297ba1..41dd5ab 100644 --- a/retail/clients/integrations/client.py +++ b/retail/clients/integrations/client.py @@ -18,3 +18,39 @@ def get_vtex_integration_detail(self, project_uuid): url, method="GET", headers=self.authentication_instance.headers ) return response.json() + + def create_template_message( + self, app_uuid: str, project_uuid: str, name: str, category: str + ) -> str: + url = f"{self.base_url}/api/v1/apps/{app_uuid}/templates/" + + payload = { + "name": name, + "category": category, + "text_preview": name, + "project_uuid": project_uuid, + } + + response = self.make_request( + url, + method="POST", + json=payload, + headers=self.authentication_instance.headers, + ) + template_uuid = response.json().get("uuid") + return template_uuid + + def create_template_translation( + self, app_uuid: str, project_uuid: str, template_uuid: str, payload: dict + ): + payload["project_uuid"] = project_uuid + + url = f"{self.base_url}/api/v1/apps/{app_uuid}/templates/{template_uuid}/translations/" + + response = self.make_request( + url, + method="POST", + json=payload, + headers=self.authentication_instance.headers, + ) + return response diff --git a/retail/clients/vtex_io/client.py b/retail/clients/vtex_io/client.py new file mode 100644 index 0000000..4d020f5 --- /dev/null +++ b/retail/clients/vtex_io/client.py @@ -0,0 +1,91 @@ +"""Client for connection with Vtex IO""" + +from django.conf import settings + +from retail.clients.base import RequestClient +from retail.interfaces.clients.vtex_io.interface import VtexIOClientInterface + + +class InternalVtexIOAuthentication(RequestClient): + """ + Handles authentication with VTEX IO using client credentials. + """ + + def __get_module_token(self) -> str: + """ + Retrieves the access token from the VTEX IO OIDC endpoint. + """ + # Authentication payload + data = { + "client_id": settings.VTEX_IO_OIDC_RP_CLIENT_ID, + "client_secret": settings.VTEX_IO_OIDC_RP_CLIENT_SECRET, + "grant_type": "client_credentials", + } + response = self.make_request( + url=settings.OIDC_OP_TOKEN_ENDPOINT, method="POST", data=data + ) + # Extracts the token + token = response.json().get("access_token") + if not token: + raise ValueError("Failed to retrieve access token.") + + return token + + @property + def token(self) -> str: + """ + Returns the access token to be used in requests. + """ + return self.__get_module_token() + + +class VtexIOClient(RequestClient, VtexIOClientInterface): + """ + Handles API communication with VTEX IO. + """ + + def __init__(self): + """ + Initializes the authentication instance. + """ + self.authentication_instance = InternalVtexIOAuthentication() + + def get_order_form_details(self, account_domain: str, order_form_id: str) -> dict: + """ + Fetches order form details by ID. + + Args: + account_domain (str): VTEX account domain. + order_form_id (str): Unique identifier for the order form. + + Returns: + dict: Order form details. + """ + url = f"https://{account_domain}/_v/order-form-details" + params = { + "orderFormId": order_form_id, + "token": self.authentication_instance.token, + } + response = self.make_request(url, method="GET", params=params) + + return response.json() + + def get_order_details(self, account_domain: str, user_email: str) -> dict: + """ + Fetches order details by user email. + + Args: + account_domain (str): VTEX account domain. + user_email (str): Email address of the user. + + Returns: + dict: Order details. + """ + url = f"https://{account_domain}/_v/orders-by-email" + params = { + "user_email": user_email, + "token": self.authentication_instance.token, + } + response = self.make_request(url, method="GET", params=params) + + return response.json() diff --git a/retail/features/migrations/0015_feature_can_vtex_integrate_feature_config_and_more.py b/retail/features/migrations/0015_feature_can_vtex_integrate_feature_config_and_more.py new file mode 100644 index 0000000..a4b86a8 --- /dev/null +++ b/retail/features/migrations/0015_feature_can_vtex_integrate_feature_config_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.1 on 2024-11-13 13:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("features", "0014_feature_status_alter_featureversion_action_types"), + ] + + operations = [ + migrations.AddField( + model_name="feature", + name="can_vtex_integrate", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="feature", + name="config", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="integratedfeature", + name="created_by_vtex", + field=models.BooleanField(default=False), + ), + ] diff --git a/retail/features/migrations/0016_integratedfeature_config.py b/retail/features/migrations/0016_integratedfeature_config.py new file mode 100644 index 0000000..e519c92 --- /dev/null +++ b/retail/features/migrations/0016_integratedfeature_config.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.1 on 2024-11-29 20:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("features", "0015_feature_can_vtex_integrate_feature_config_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="integratedfeature", + name="config", + field=models.JSONField(default=dict), + ), + ] diff --git a/retail/features/models.py b/retail/features/models.py index 87d7f99..964e43a 100644 --- a/retail/features/models.py +++ b/retail/features/models.py @@ -43,6 +43,9 @@ class Feature(models.Model): blank=True ) + can_vtex_integrate = models.BooleanField(default=False) + config = models.JSONField(default=dict) + def __str__(self): return self.name @@ -139,6 +142,8 @@ class IntegratedFeature(models.Model): User, on_delete=models.CASCADE, related_name="integrated_features" ) integrated_on = models.DateField(auto_now_add=True) + created_by_vtex = models.BooleanField(default=False) + config = models.JSONField(default=dict) # def save(self, *args) -> None: # self.feature = self.feature_version.feature diff --git a/retail/interfaces/clients/flows/interface.py b/retail/interfaces/clients/flows/interface.py index 1907123..f836c53 100644 --- a/retail/interfaces/clients/flows/interface.py +++ b/retail/interfaces/clients/flows/interface.py @@ -1,7 +1,25 @@ from abc import ABC, abstractmethod +from typing import List, Optional, Dict class FlowsClientInterface(ABC): @abstractmethod def get_user_api_token(self, user_email: str, project_uuid: str): + """ + Retrieve the user API token for a given email and project UUID. + """ + pass + + @abstractmethod + def send_whatsapp_broadcast(self, payload: Dict, token: str) -> Dict: + """ + Sends a WhatsApp broadcast message. + + Args: + payload (dict): The pre-built payload containing all necessary data for the broadcast. + token (str): Authorization token for the API. + + Returns: + dict: API response containing the broadcast information. + """ pass diff --git a/retail/interfaces/clients/integrations/interface.py b/retail/interfaces/clients/integrations/interface.py index 1df03f8..53ae25d 100644 --- a/retail/interfaces/clients/integrations/interface.py +++ b/retail/interfaces/clients/integrations/interface.py @@ -5,3 +5,15 @@ class IntegrationsClientInterface(ABC): @abstractmethod def get_vtex_integration_detail(self, project_uuid: str): pass + + @abstractmethod + def create_template_message( + self, app_uuid: str, project_uuid: str, name: str, category: str + ) -> str: + pass + + @abstractmethod + def create_template_translation( + self, app_uuid: str, project_uuid: str, template_uuid: str, payload: dict + ) -> dict: + pass diff --git a/retail/interfaces/clients/vtex_io/interface.py b/retail/interfaces/clients/vtex_io/interface.py new file mode 100644 index 0000000..402ea62 --- /dev/null +++ b/retail/interfaces/clients/vtex_io/interface.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + + +class VtexIOClientInterface(ABC): + @abstractmethod + def get_order_form_details(self, account_domain: str, order_form_id: str) -> dict: + pass + + @abstractmethod + def get_order_details(self, account_domain: str, user_email: str) -> dict: + pass diff --git a/retail/projects/migrations/0004_project_vtex_account.py b/retail/projects/migrations/0004_project_vtex_account.py new file mode 100644 index 0000000..2b19d5e --- /dev/null +++ b/retail/projects/migrations/0004_project_vtex_account.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-11-22 17:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0003_project_organization_uuid"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="vtex_account", + field=models.CharField(max_length=100, null=True), + ), + ] diff --git a/retail/projects/models.py b/retail/projects/models.py index 22bd96a..c3af7f9 100644 --- a/retail/projects/models.py +++ b/retail/projects/models.py @@ -5,6 +5,7 @@ class Project(models.Model): name = models.CharField(max_length=256) uuid = models.UUIDField() organization_uuid = models.UUIDField(null=True) + vtex_account = models.CharField(max_length=100, null=True) def __str__(self) -> str: return self.name diff --git a/retail/services/flows/service.py b/retail/services/flows/service.py index 2204733..75906d6 100644 --- a/retail/services/flows/service.py +++ b/retail/services/flows/service.py @@ -9,10 +9,33 @@ def __init__(self, client: FlowsClientInterface): def get_user_api_token(self, user_email: str, project_uuid: str) -> dict: """ Retrieve the user API token for a given email and project UUID. - Handles communication errors and returns None in case of failure. """ try: return self.client.get_user_api_token(user_email, project_uuid) except CustomAPIException as e: - print(f"Error {e.status_code} when retrieving user API token for project {project_uuid}.") + print( + f"Error {e.status_code} when retrieving user API token for project {project_uuid}." + ) return None + + def send_whatsapp_broadcast( + self, payload: dict, project_uuid: str, user_email: str + ) -> dict: + """ + Send a WhatsApp broadcast message. + + Args: + payload (dict): The full body of the request as a pre-built payload. + project_uuid (str): The UUID of the project. + user_email (str): Email of the user for authentication. + + Returns: + dict: API response from the Flows service. + """ + # Retrieve the API token + token = self.client.get_user_api_token(user_email, project_uuid) + if not token: + raise CustomAPIException("Failed to retrieve API token.") + + # Send the broadcast using the token + return self.client.send_whatsapp_broadcast(payload=payload, token=token) diff --git a/retail/services/integrations/service.py b/retail/services/integrations/service.py index 53fed70..834aebd 100644 --- a/retail/services/integrations/service.py +++ b/retail/services/integrations/service.py @@ -14,5 +14,119 @@ def get_vtex_integration_detail(self, project_uuid: str) -> dict: try: return self.client.get_vtex_integration_detail(project_uuid) except CustomAPIException as e: - print(f"Error {e.status_code} when retrieving VTEX integration for project {project_uuid}.") + print( + f"Error {e.status_code} when retrieving VTEX integration for project {project_uuid}." + ) return None + + def create_abandoned_cart_template( + self, app_uuid: str, project_uuid: str, store: str + ) -> dict: + """ + Creates an abandoned cart template and translations for multiple languages. + """ + try: + # Create Template + template_uuid = self.client.create_template_message( + app_uuid=app_uuid, + project_uuid=project_uuid, + name="weni_abandoned_cart_notification", + category="MARKETING", + ) + + # Prepare translations for multiple languages + translations = [ + { + "language": "pt_BR", + "body": { + "type": "BODY", + "text": ( + "Olá, {{1}} vimos que você deixou itens no seu carrinho 🛒. " + "\nVamos fechar o pedido e garantir essas ofertas? " + "\n\nClique em Finalizar Pedido para concluir sua compra 👇" + ), + "example": {"body_text": [["João"]]}, + }, + "footer": {"type": "FOOTER", "text": "Finalizar Pedido"}, + "buttons": [ + { + "button_type": "URL", + "text": "Finalizar Pedido", + "url": f"https://{store}/checkout/cart/add?sc=1{{1}}", + "example": ["&sku=1&qty=1"], + }, + { + "button_type": "QUICK_REPLY", + "text": "Parar Promoções", + }, + ], + }, + { + "language": "es", + "body": { + "type": "BODY", + "text": ( + "Hola, {{1}} notamos que dejaste artículos en tu carrito 🛒. " + "\n¿Listo para completar tu pedido y asegurar estas ofertas? " + "\n\nHaz clic en Finalizar Pedido para completar tu compra 👇" + ), + "example": {"body_text": [["Juan"]]}, + }, + "footer": {"type": "FOOTER", "text": "Finalizar Pedido"}, + "buttons": [ + { + "button_type": "URL", + "text": "Finalizar Pedido", + "url": f"https://{store}/checkout/cart/add?sc=1{{1}}", + "example": ["&sku=1&qty=1"], + }, + { + "button_type": "QUICK_REPLY", + "text": "Parar Promociones", + }, + ], + }, + { + "language": "en", + "body": { + "type": "BODY", + "text": ( + "Hello, {{1}} we noticed you left items in your cart 🛒. " + "\nReady to complete your order and secure these deals? " + "\n\nClick Finish Order to complete your purchase 👇" + ), + "example": {"body_text": [["John"]]}, + }, + "footer": {"type": "FOOTER", "text": "Finish Order"}, + "buttons": [ + { + "button_type": "URL", + "text": "Finish Order", + "url": f"https://{store}/checkout/cart/add?sc=1{{1}}", + "example": ["&sku=1&qty=1"], + }, + { + "button_type": "QUICK_REPLY", + "text": "Stop Promotions", + }, + ], + }, + ] + + # Create translations for each language + for translation in translations: + self.client.create_template_translation( + app_uuid=app_uuid, + project_uuid=project_uuid, + template_uuid=template_uuid, + payload=translation, + ) + print(f"Translation created for language {translation['language']}.") + + return {"template_uuid": template_uuid} + + except CustomAPIException as e: + print( + f"Error {e.status_code} during template or translation creation: {str(e)}" + ) + raise diff --git a/retail/services/vtex_io/service.py b/retail/services/vtex_io/service.py new file mode 100644 index 0000000..db0a025 --- /dev/null +++ b/retail/services/vtex_io/service.py @@ -0,0 +1,46 @@ +from retail.interfaces.clients.vtex_io.interface import VtexIOClientInterface + + +class VtexIOService: + """ + Service for interacting with VTEX IO APIs. + Provides methods to fetch order form details and order history based on email. + """ + + def __init__(self, client: VtexIOClientInterface): + """ + Initialize the VTEX IO service with the provided client. + + Args: + client (VtexIOClientInterface): The client interface for VTEX IO. + """ + self.client = client + + def get_order_form_details(self, account_domain: str, order_form_id: str) -> dict: + """ + Retrieve order form details from VTEX IO. + + Args: + account_domain (str): The domain of the VTEX account. + order_form_id (str): The unique identifier of the order form. + + Returns: + dict: The order form details if successful + + """ + return self.client.get_order_form_details(account_domain, order_form_id) + + def get_order_details(self, account_domain: str, user_email: str) -> dict: + """ + Retrieve order details by user email from VTEX IO. + + Args: + account_domain (str): The domain of the VTEX account. + user_email (str): The email address of the user. + + Returns: + dict: The order details if successful + + """ + + return self.client.get_order_details(account_domain, user_email) diff --git a/retail/settings.py b/retail/settings.py index 5f66127..f9fe446 100644 --- a/retail/settings.py +++ b/retail/settings.py @@ -69,6 +69,7 @@ "retail.healthcheck", "retail.internal", "rest_framework", + "retail.vtex", ] MIDDLEWARE = [ @@ -193,6 +194,32 @@ EMAILS_CAN_TESTING = env.str("EMAILS_CAN_TESTING", "").split(",") +ABANDONED_CART_FEATURE_UUID = env.str("ABANDONED_CART_FEATURE_UUID", "") + +FLOWS_USER_CRM_EMAIL = env.str("FLOWS_USER_CRM_EMAIL", "") + +# Redis +REDIS_URL = env.str("REDIS_URL", default="redis://localhost:6379") + + +# Celery +CELERY_BROKER_URL = env.str("CELERY_BROKER_URL", default=REDIS_URL) +CELERY_RESULT_BACKEND = None +CELERY_TASK_IGNORE_RESULT = True +CELERY_ACCEPT_CONTENT = ["application/json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = TIME_ZONE + + +# Cache +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + } +} OIDC_CACHE_TOKEN = env.bool( "OIDC_CACHE_TOKEN", default=False @@ -200,3 +227,6 @@ OIDC_CACHE_TTL = env.int( "OIDC_CACHE_TTL", default=600 ) # Time-to-live for cached user tokens (default: 600 seconds). + +VTEX_IO_OIDC_RP_CLIENT_SECRET = env.str("VTEX_IO_OIDC_RP_CLIENT_SECRET", "") +VTEX_IO_OIDC_RP_CLIENT_ID = env.str("VTEX_IO_OIDC_RP_CLIENT_ID", "") diff --git a/retail/urls.py b/retail/urls.py index 0f28618..f70487d 100644 --- a/retail/urls.py +++ b/retail/urls.py @@ -26,6 +26,8 @@ from retail.healthcheck import views from retail.projects import views as project_views from retail.api import routers as feature_routers +from retail.webhooks import urls as webhooks_urls + router = routers.SimpleRouter() router.register("projects", project_views.ProjectViewSet, basename="project") @@ -37,6 +39,7 @@ path("healthcheck/", views.healthcheck, name="healthcheck"), path("api/", include(router.urls)), path("v2/", include(feature_routers)), + path("", include(webhooks_urls)), ] urlpatterns.append( diff --git a/retail/vtex/__init__.py b/retail/vtex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/retail/vtex/apps.py b/retail/vtex/apps.py new file mode 100644 index 0000000..4a8ee87 --- /dev/null +++ b/retail/vtex/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class VtexConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "retail.vtex" diff --git a/retail/vtex/migrations/0001_initial.py b/retail/vtex/migrations/0001_initial.py new file mode 100644 index 0000000..69bb413 --- /dev/null +++ b/retail/vtex/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 5.1.1 on 2024-12-30 14:18 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("features", "0016_integratedfeature_config"), + ("projects", "0004_project_vtex_account"), + ] + + operations = [ + migrations.CreateModel( + name="Cart", + 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), + ), + ("cart_id", models.CharField(blank=True, null=True)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("modified_on", models.DateTimeField(auto_now=True)), + ( + "status", + models.CharField( + choices=[ + ("created", "Created"), + ("purchased", "Purchased"), + ("delivered_success", "Delivered Success"), + ("delivered_error", "Delivered Error"), + ("empty", "Empty"), + ], + default="created", + max_length=20, + verbose_name="Status of Cart", + ), + ), + ("phone_number", models.CharField(max_length=15)), + ("config", models.JSONField(default=dict)), + ("abandoned", models.BooleanField(default=False)), + ("error_message", models.TextField(blank=True, null=True)), + ( + "integrated_feature", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="carts_by_feature", + to="features.integratedfeature", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="carts_by_project", + to="projects.project", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["project", "status"], + name="vtex_cart_project_da4d1d_idx", + ) + ], + }, + ), + ] diff --git a/retail/vtex/migrations/__init__.py b/retail/vtex/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/retail/vtex/models.py b/retail/vtex/models.py new file mode 100644 index 0000000..490c0c2 --- /dev/null +++ b/retail/vtex/models.py @@ -0,0 +1,43 @@ +import uuid + +from django.db import models +from retail.projects.models import Project +from retail.features.models import IntegratedFeature + + +class Cart(models.Model): + STATUS_CHOICES = [ + ("created", "Created"), + ("purchased", "Purchased"), + ("delivered_success", "Delivered Success"), + ("delivered_error", "Delivered Error"), + ("empty", "Empty"), + ] + + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + cart_id = models.CharField(null=True, blank=True) + created_on = models.DateTimeField(auto_now_add=True) + modified_on = models.DateTimeField(auto_now=True) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default="created", + verbose_name="Status of Cart", + ) + phone_number = models.CharField(max_length=15) + config = models.JSONField(default=dict) + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="carts_by_project" + ) + integrated_feature = models.ForeignKey( + IntegratedFeature, on_delete=models.CASCADE, related_name="carts_by_feature" + ) + abandoned = models.BooleanField(default=False) + error_message = models.TextField(blank=True, null=True) + + def __str__(self): + status = "Abandoned" if self.abandoned else self.status.capitalize() + return f"Cart: {self.phone_number}, Status: {status}, Last Modified: {self.modified_on:%Y-%m-%d %H:%M:%S}" + + class Meta: + indexes = [models.Index(fields=["project", "status"])] diff --git a/retail/vtex/tasks.py b/retail/vtex/tasks.py new file mode 100644 index 0000000..6221f18 --- /dev/null +++ b/retail/vtex/tasks.py @@ -0,0 +1,14 @@ +from celery import shared_task +from retail.vtex.usecases.cart_abandonment import CartAbandonmentUseCase + + +@shared_task +def mark_cart_as_abandoned(cart_uuid: str): + """ + Mark a cart as abandoned and trigger the broadcast notification process. + + Args: + cart_uuid (str): The UUID of the cart to process. + """ + use_case = CartAbandonmentUseCase() + use_case.process_abandoned_cart(cart_uuid) diff --git a/retail/vtex/usecases/cart_abandonment.py b/retail/vtex/usecases/cart_abandonment.py new file mode 100644 index 0000000..0901f33 --- /dev/null +++ b/retail/vtex/usecases/cart_abandonment.py @@ -0,0 +1,307 @@ +import logging + +from django.conf import settings + +from retail.clients.vtex_io.client import VtexIOClient +from retail.services.vtex_io.service import VtexIOService +from retail.vtex.models import Cart +from retail.services.flows.service import FlowsService +from retail.clients.flows.client import FlowsClient +from retail.clients.exceptions import CustomAPIException +from retail.vtex.usecases.phone_number_normalizer import PhoneNumberNormalizer + + +logger = logging.getLogger(__name__) + + +class CartAbandonmentUseCase: + """ + Use case for handling cart abandonment and notifications. + """ + + def __init__( + self, + flows_service: FlowsService = None, + vtex_client: VtexIOService = None, + message_builder=None, + ): + """ + Initialize dependencies for the CartAbandonmentUseCase. + + Args: + flows_service (FlowsService): Service to handle notification flows. + vtex_client (VtexIOService): Client for interacting with VTEX API. + message_builder (MessageBuilder): Builder for constructing notification messages. + """ + self.flows_service = flows_service or FlowsService(FlowsClient()) + self.vtex_client = vtex_client or VtexIOService(VtexIOClient()) + self.message_builder = message_builder or MessageBuilder() + + def process_abandoned_cart(self, cart_uuid: str): + """ + Process a cart marked as abandoned. + + Args: + cart_uuid (str): The UUID of the cart to process. + """ + try: + # Fetch the cart + cart = self._get_cart(cart_uuid) + + # Fetch order form details from VTEX IO + order_form = self._fetch_order_form(cart) + + # Process and update cart information + client_profile = self._extract_client_profile(cart, order_form) + + if not order_form.get("items", []): + # Mark cart as empty if no items are found + self._update_cart_status(cart, "empty") + return + + # Check orders by email + orders = self._fetch_orders_by_email(cart, client_profile["email"]) + self._evaluate_orders(cart, orders) + + except Cart.DoesNotExist: + logger.warning( + f"Cart with UUID {cart_uuid} does not exist or is already processed." + ) + except CustomAPIException as e: + logger.error( + f"Unexpected error while processing cart {cart_uuid}: {str(e)}" + ) + self._handle_error(cart_uuid, str(e)) + + def _get_cart(self, cart_uuid: str) -> Cart: + """ + Retrieve the cart instance by UUID. + + Args: + cart_uuid (str): The UUID of the cart. + + Returns: + Cart: The cart instance. + + Raises: + Cart.DoesNotExist: If no cart is found with the given UUID. + """ + return Cart.objects.get(uuid=cart_uuid, status="created") + + def _fetch_order_form(self, cart: Cart) -> dict: + """ + Retrieve order form details from VTEX API. + + Args: + cart (Cart): The cart instance. + + Returns: + dict: The order form details. + + Raises: + CustomAPIException: If the API request fails. + """ + + order_form = self.vtex_client.get_order_form_details( + account_domain=self._get_account_domain(cart), order_form_id=cart.cart_id + ) + if not order_form: + logger.warning( + f"Order form for {cart.project.vtex_account}-{cart.uuid} is empty." + ) + raise CustomAPIException("Empty order form.") + + return order_form + + def _extract_client_profile(self, cart: Cart, order_form: dict) -> dict: + """ + Extract and normalize client profile data from order form. + + Args: + cart (Cart): The cart instance. + order_form (dict): Order form details. + + Returns: + dict: Normalized client profile data. + """ + client_profile = order_form.get("clientProfileData", {}) + phone = client_profile.get("phone") + + if phone: + normalized_phone = PhoneNumberNormalizer.normalize(phone) + cart.phone_number = normalized_phone + + # Update cart configuration and phone number + cart.config["client_profile"] = client_profile + cart.save() + + return client_profile + + def _fetch_orders_by_email(self, cart: Cart, email: str) -> dict: + """ + Fetch orders associated with a given email. + + Args: + cart (Cart): The cart instance. + email (str): The client email address. + + Returns: + dict: List of orders associated with the email. + """ + orders = self.vtex_client.get_order_details( + account_domain=self._get_account_domain(cart), user_email=email + ) + return orders or {"list": []} + + def _evaluate_orders(self, cart: Cart, orders: dict): + """ + Evaluate orders and determine the status of the cart. + + Args: + cart (Cart): The cart instance. + orders (dict): List of orders retrieved. + """ + if not orders.get("list"): + self._mark_cart_as_abandoned(cart) + return + + recent_orders = orders.get("list", [])[:3] + for order in recent_orders: + if order.get("orderFormId") == cart.cart_id: + self._update_cart_status(cart, "purchased") + return + + self._mark_cart_as_abandoned(cart) + + def _mark_cart_as_abandoned(self, cart: Cart): + """ + Mark a cart as abandoned and send notification. + + Args: + cart (Cart): The cart to process. + """ + self._update_cart_status(cart, "abandoned") + + # Prepare and send the notification + payload = self.message_builder.build_abandonment_message(cart) + response = self.flows_service.send_whatsapp_broadcast( + payload=payload, + project_uuid=cart.project.uuid, + user_email=settings.FLOWS_USER_CRM_EMAIL, + ) + self._update_cart_status(cart, "delivered_success", response) + + def _update_cart_status(self, cart: Cart, status: str, response=None): + """ + Update the cart's status and log errors if applicable. + + Args: + cart (Cart): The cart to update. + status (str): The new status to set. + response (dict, optional): The response from the broadcast service. + """ + cart.status = status + if status == "delivered_error" and response: + cart.error_message = f"Broadcast failed: {response}" + cart.save() + + def _handle_error(self, cart_uuid: str, error_message: str): + """ + Handle errors by logging and updating the cart status. + + Args: + cart_uuid (str): The UUID of the cart. + error_message (str): Error message to log. + """ + try: + cart = Cart.objects.get(uuid=cart_uuid) + self._update_cart_status(cart, "delivered_error", error_message) + except Cart.DoesNotExist: + logger.error(f"Cart not found during error handling: {error_message}") + + def _get_account_domain(self, cart: Cart) -> str: + return f"dev5--{cart.project.vtex_account}.myvtex.com" + + +class MessageBuilder: + """ + Helper to build broadcast message payloads for abandoned cart notifications. + """ + + def build_abandonment_message(self, cart: Cart) -> dict: + """ + Build the message payload for an abandoned cart notification. + + Args: + cart (Cart): The cart for which to build the message. + + Returns: + dict: The message payload. + + Raises: + ValueError: If required data is missing in the cart or feature. + """ + # Fetch required data from the cart's integrated feature + template_uuid = self._get_feature_config_value(cart, "template_message") + channel_uuid = self._get_feature_config_value(cart, "flow_channel_uuid") + + # Fetch cart-specific data + cart_link = self._get_cart_config_value(cart, "cart_url") + + # Build the payload + return { + "urns": [f"whatsapp:{cart.phone_number}"], + "channel": channel_uuid, + "msg": { + "template": { + "uuid": template_uuid, + "variables": ["@contact.name"], + }, + "buttons": [ + { + "sub_type": "url", + "parameters": [{"type": "text", "text": cart_link}], + } + ], + }, + } + + def _get_feature_config_value(self, cart: Cart, key: str) -> str: + """ + Helper method to retrieve a configuration value from the integrated feature. + + Args: + cart (Cart): The cart containing the integrated feature. + key (str): The key to fetch from the feature's configuration. + + Returns: + str: The value associated with the key. + + Raises: + ValueError: If the key is missing. + """ + value = cart.integrated_feature.config.get(key) + if not value: + raise ValueError( + f"Failed to retrieve '{key}' from feature '{cart.integrated_feature.feature.name}'." + ) + return value + + def _get_cart_config_value(self, cart: Cart, key: str) -> str: + """ + Helper method to retrieve a configuration value from the cart. + + Args: + cart (Cart): The cart to fetch the value from. + key (str): The key to fetch from the cart's configuration. + + Returns: + str: The value associated with the key. + + Raises: + ValueError: If the key is missing. + """ + value = cart.config.get(key) + if not value: + raise ValueError(f"Failed to retrieve '{key}' from the cart configuration.") + return value diff --git a/retail/vtex/usecases/phone_number_normalizer.py b/retail/vtex/usecases/phone_number_normalizer.py new file mode 100644 index 0000000..5fde5b4 --- /dev/null +++ b/retail/vtex/usecases/phone_number_normalizer.py @@ -0,0 +1,42 @@ +import re +from rest_framework.exceptions import ValidationError + + +class PhoneNumberNormalizer: + """ + Helper class to normalize phone numbers into the format: CC DDD NUMBER + """ + + @staticmethod + def normalize(phone_number: str) -> str: + """ + Normalize a phone number to the format CC DDD NUMBER (e.g., 5584987654321). + + Args: + phone_number (str): The phone number to normalize. + + Returns: + str: The normalized phone number. + + Raises: + ValidationError: If the phone number cannot be normalized. + """ + # Check if the number is empty or censored (contains '*') + if not phone_number or "*" in phone_number: + raise ValidationError(f"Invalid or censored phone number: {phone_number}") + + # Remove all non-numeric characters except the leading "+" + phone_number = re.sub(r"[^\d+]", "", phone_number) + + # Remove multiple "+" and keep only one at the beginning (if present) + if phone_number.startswith("++"): + phone_number = phone_number.lstrip("+") + + # Remove any remaining "+" and keep only digits + phone_number = phone_number.lstrip("+") + + # Validate if the number has at least 10 digits (CC + DDD + Number) + if len(phone_number) < 10: + raise ValidationError(f"Invalid phone number length: {phone_number}") + + return phone_number diff --git a/retail/webhooks/__init__.py b/retail/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/retail/webhooks/urls.py b/retail/webhooks/urls.py new file mode 100644 index 0000000..2308523 --- /dev/null +++ b/retail/webhooks/urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path("webhook/", include("retail.webhooks.vtex.urls")), +] diff --git a/retail/webhooks/vtex/dtos/cart_dto.py b/retail/webhooks/vtex/dtos/cart_dto.py new file mode 100644 index 0000000..deab2b4 --- /dev/null +++ b/retail/webhooks/vtex/dtos/cart_dto.py @@ -0,0 +1,13 @@ +from typing import Dict +from dataclasses import dataclass + + +@dataclass +class CartDTO: + """ + Data Transfer Object (DTO) for handling cart data. + """ + action: str # The action to perform, e.g., 'create', 'update', etc. + account: str # The VTEX account identifier + home_phone: str # The user's phone number + data: Dict # The cart details to be stored in the model diff --git a/retail/webhooks/vtex/serializers.py b/retail/webhooks/vtex/serializers.py new file mode 100644 index 0000000..616df1e --- /dev/null +++ b/retail/webhooks/vtex/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + + +class CartSerializer(serializers.Serializer): + """ + Serializer to validate cart data received from VTEX. + """ + + account = serializers.CharField() + cart_id = serializers.CharField() diff --git a/retail/webhooks/vtex/urls.py b/retail/webhooks/vtex/urls.py new file mode 100644 index 0000000..cdbba75 --- /dev/null +++ b/retail/webhooks/vtex/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from .views.abandoned_cart_notification import AbandonedCartNotification + + +urlpatterns = [ + path( + "vtex/abandoned-cart/api/notification/", + AbandonedCartNotification.as_view(), + name="abandoned-cart", + ), +] diff --git a/retail/webhooks/vtex/usecases/cart.py b/retail/webhooks/vtex/usecases/cart.py new file mode 100644 index 0000000..02a268d --- /dev/null +++ b/retail/webhooks/vtex/usecases/cart.py @@ -0,0 +1,139 @@ +import logging + +from django.conf import settings + +from rest_framework.exceptions import ValidationError, NotFound +from retail.features.models import Feature, IntegratedFeature +from retail.projects.models import Project +from retail.vtex.models import Cart +from retail.vtex.tasks import mark_cart_as_abandoned + +from retail.celery import app as celery_app + + +logger = logging.getLogger(__name__) + + +def generate_task_key(cart_uuid: str) -> str: + """ + Generate a deterministic task key using the cart UUID. + """ + return f"abandonment-task-{cart_uuid}" + + +class CartUseCase: + """ + Centralized use case for handling cart actions. + """ + + def __init__(self, account: str): + self.account = account + self.project = self._get_project_by_account() + self.feature = self._get_feature() + + def _get_project_by_account(self) -> Project: + """ + Fetch the project associated with the account. + + Raises: + NotFound: If the project is not found. + + Returns: + Project: The associated project instance. + """ + try: + return Project.objects.get(vtex_account=self.account) + except Project.DoesNotExist: + raise NotFound(f"Project with account '{self.account}' does not exist.") + + def _get_feature(self) -> IntegratedFeature: + """ + Retrieve the IntegratedFeature for the abandoned cart notification feature + associated with the current project. + + This method fetches the `IntegratedFeature` associated with the abandoned cart + functionality for the project linked to this use case. + + Raises: + NotFound: If the feature or the integration is not found for the project. + ValidationError: For any other unexpected errors. + + Returns: + IntegratedFeature: The integrated feature instance for the abandoned cart. + """ + try: + abandoned_cart_feature_uuid = settings.ABANDONED_CART_FEATURE_UUID + feature = Feature.objects.get(uuid=abandoned_cart_feature_uuid) + return IntegratedFeature.objects.get(project=self.project, feature=feature) + except Feature.DoesNotExist: + error_message = ( + f"Feature with UUID {abandoned_cart_feature_uuid} not found." + ) + logger.error(error_message, exc_info=True) + raise NotFound(error_message) + except IntegratedFeature.DoesNotExist: + error_message = f"IntegratedFeature for project '{self.project}' and feature '{feature}' not found." + logger.error(error_message, exc_info=True) + raise NotFound(error_message) + except Exception as e: + error_message = ( + f"An unexpected error occurred while retrieving the feature: {str(e)}" + ) + logger.error(error_message, exc_info=True) # Captura o traceback completo + raise ValidationError(error_message) + + def process_cart_notification(self, cart_id: str) -> Cart: + """ + Process incoming cart notification, renewing task or creating new cart. + + Args: + cart_id (str): The unique identifier for the cart. + + Returns: + Cart: The created or updated cart instance. + """ + try: + # Check if the cart already exists + cart = Cart.objects.get( + cart_id=cart_id, project=self.project, status="created" + ) + # Renew abandonment task + self._schedule_abandonment_task(str(cart.uuid)) + return cart + except Cart.DoesNotExist: + # Create new cart if it doesn't exist + return self._create_cart(cart_id) + + def _create_cart(self, cart_id: str) -> Cart: + """ + Create a new cart entry and schedule an abandonment task. + + Args: + cart_id (str): The UUID of the cart. + + Returns: + Cart: The created cart instance. + """ + cart = Cart.objects.create( + cart_id=cart_id, + status="created", + project=self.project, + integrated_feature=self._get_feature(), + ) + + # Schedule abandonment task + self._schedule_abandonment_task(str(cart.uuid)) + return cart + + def _schedule_abandonment_task(self, cart_uuid: str): + """ + Schedule a task to mark a cart as abandoned after 25 minutes. + + Args: + cart_uuid (str): The UUID of the cart. + """ + task_key = generate_task_key(cart_uuid) + + mark_cart_as_abandoned.apply_async( + (cart_uuid,), countdown=25 * 60, task_id=task_key + ) diff --git a/retail/webhooks/vtex/views/abandoned_cart_notification.py b/retail/webhooks/vtex/views/abandoned_cart_notification.py new file mode 100644 index 0000000..146192c --- /dev/null +++ b/retail/webhooks/vtex/views/abandoned_cart_notification.py @@ -0,0 +1,38 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from retail.internal.permissions import CanCommunicateInternally +from retail.webhooks.vtex.serializers import CartSerializer +from retail.webhooks.vtex.usecases.cart import CartUseCase + + +class AbandonedCartNotification(APIView): + """ + Handle abandoned cart notifications. + """ + permission_classes = [CanCommunicateInternally] + + def post(self, request): + # Validação dos dados recebidos + serializer = CartSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Extrai dados validados + validated_data = serializer.validated_data + account = validated_data["account"] + cart_id = validated_data["cart_id"] + + # Processa a notificação + cart_use_case = CartUseCase(account=account) + result = cart_use_case.process_cart_notification(cart_id) + + return Response( + { + "message": "Cart processed successfully.", + "cart_uuid": str(result.uuid), + "cart_id": str(result.cart_id), + "status": result.status, + }, + status=status.HTTP_200_OK, + )