diff --git a/ecommerce/hubspot_api.py b/ecommerce/hubspot_api.py new file mode 100644 index 000000000..41a223d13 --- /dev/null +++ b/ecommerce/hubspot_api.py @@ -0,0 +1,135 @@ +""" +Hubspot Ecommerce Bridge API sync utilities + +https://developers.hubspot.com/docs/methods/ecomm-bridge/ecomm-bridge-overview +""" +import time +from urllib.parse import urljoin, urlencode +import requests +from django.conf import settings + + +HUBSPOT_API_BASE_URL = "https://api.hubapi.com" + + +def send_hubspot_request( + endpoint, api_url, method, body=None, query_params=None, **kwargs +): + """ + Send a request to Hubspot using the given params, body and api key specified in settings + + Args: + endpoint (String): Specific endpoint to hit. Can be the empty string + api_url (String): The url path to append endpoint to + method (String): GET, POST, or PUT + body (serializable data): Data to be JSON serialized and sent with a PUT or POST request + query_params (Dict): Params to be added to the query string + kwargs: keyword arguments to add to the request method + Returns: + HTML response to the constructed url + """ + + base_url = urljoin(f"{HUBSPOT_API_BASE_URL}/", api_url) + if endpoint: + base_url = urljoin(f"{base_url}/", endpoint) + if query_params is None: + query_params = {} + if "hapikey" not in query_params: + query_params["hapikey"] = settings.HUBSPOT_API_KEY + params = urlencode(query_params) + url = f"{base_url}?{params}" + if method == "GET": + return requests.get(url=url, **kwargs) + if method == "PUT": + return requests.put(url=url, json=body, **kwargs) + if method == "POST": + return requests.post(url=url, json=body, **kwargs) + + +def make_sync_message(object_id, properties): + """ + Create data for sync message + Args: + object_id (ObjectID): Internal ID to match with Hubspot object + properties (dict): dict of properties to be synced + Returns: + dict to be serialized as body in sync-message + """ + for key in properties.keys(): + if properties[key] is None: + properties[key] = "" + return { + "integratorObjectId": str(object_id), + "action": "UPSERT", + "changeOccurredTimestamp": int(time.time() * 1000), + "propertyNameToValues": dict(properties), + } + + +def get_sync_errors(limit=200, offset=0): + """ + Get errors that have occurred during sync + Args: + limit (Int): The number of errors to be returned + offset (Int): The index of the first error to be returned + Returns: + HTML response including error data + """ + response = send_hubspot_request( + "sync-errors", + "/extensions/ecomm/v1", + "GET", + query_params={"limit": limit, "offset": offset}, + ) + response.raise_for_status() + return response + + +def make_contact_sync_message(user_id): + """ + Create the body of a sync message for a contact. This will flatten the contained LegalAddress and Profile + serialized data into one larger serializable dict + Args: + user_id (ObjectID): ID of user to sync contact with + Returns: + dict containing serializable sync-message data + """ + from users.models import User + from users.serializers import UserSerializer + + user = User.objects.get(id=user_id) + properties = UserSerializer(user).data + properties.update(properties.pop("legal_address") or {}) + properties.update(properties.pop("profile") or {}) + properties["street_address"] = "\n".join(properties.pop("street_address")) + return make_sync_message(user.id, properties) + + +def make_deal_sync_message(order_id): + """ + Create the body of a sync message for a deal. + Args: + Returns: + dict containing serializable sync-message data + """ + return make_sync_message(order_id, {}) + + +def make_line_item_sync_message(line_id): + """ + Create the body of a sync message for a line item. + Args: + Returns: + dict containing serializable sync-message data + """ + return make_sync_message(line_id, {}) + + +def make_product_sync_message(product_id): + """ + Create the body of a sync message for a product. + Args: + Returns: + dict containing serializable sync-message data + """ + return make_sync_message(product_id, {}) diff --git a/ecommerce/hubspot_api_test.py b/ecommerce/hubspot_api_test.py new file mode 100644 index 000000000..afa736094 --- /dev/null +++ b/ecommerce/hubspot_api_test.py @@ -0,0 +1,102 @@ +""" +Hubspot API tests +""" +# pylint: disable=redefined-outer-name +from urllib.parse import urlencode + +import pytest +from faker import Faker +from django.conf import settings + +from ecommerce.hubspot_api import ( + send_hubspot_request, + HUBSPOT_API_BASE_URL, + make_sync_message, + make_contact_sync_message, +) +from users.serializers import UserSerializer + +fake = Faker() + + +@pytest.mark.parametrize("request_method", ["GET", "PUT", "POST"]) +@pytest.mark.parametrize( + "endpoint,api_url,expected_url", + [ + [ + "sync-errors", + "/extensions/ecomm/v1", + f"{HUBSPOT_API_BASE_URL}/extensions/ecomm/v1/sync-errors", + ], + [ + "", + "/extensions/ecomm/v1/installs", + f"{HUBSPOT_API_BASE_URL}/extensions/ecomm/v1/installs", + ], + [ + "CONTACT", + "/extensions/ecomm/v1/sync-messages", + f"{HUBSPOT_API_BASE_URL}/extensions/ecomm/v1/sync-messages/CONTACT", + ], + ], +) +def test_send_hubspot_request(mocker, request_method, endpoint, api_url, expected_url): + """Test sending hubspot request with method = GET""" + value = fake.pyint() + query_params = {"param": value} + + # Include hapikey when generating url to match request call against + full_query_params = {"param": value, "hapikey": settings.HUBSPOT_API_KEY} + mock_request = mocker.patch( + f"ecommerce.hubspot_api.requests.{request_method.lower()}" + ) + url_params = urlencode(full_query_params) + url = f"{expected_url}?{url_params}" + if request_method == "GET": + send_hubspot_request( + endpoint, api_url, request_method, query_params=query_params + ) + mock_request.assert_called_once_with(url=url) + else: + body = fake.pydict() + send_hubspot_request( + endpoint, api_url, request_method, query_params=query_params, body=body + ) + mock_request.assert_called_once_with(url=url, json=body) + + +def test_make_sync_message(): + """Test make_sync_message produces a properly formatted sync-message""" + object_id = fake.pyint() + value = fake.word() + properties = {"prop": value, "blank": None} + sync_message = make_sync_message(object_id, properties) + time = sync_message["changeOccurredTimestamp"] + assert sync_message == ( + { + "integratorObjectId": str(object_id), + "action": "UPSERT", + "changeOccurredTimestamp": time, + "propertyNameToValues": {"prop": value, "blank": ""}, + } + ) + + +def test_make_contact_sync_message(user): + """Test make_contact_sync_message serializes a user and returns a properly formatted sync message""" + contact_sync_message = make_contact_sync_message(user.id) + + serialized_user = UserSerializer(user).data + serialized_user.update(serialized_user.pop("legal_address") or {}) + serialized_user.update(serialized_user.pop("profile") or {}) + serialized_user["street_address"] = "\n".join(serialized_user.pop("street_address")) + + time = contact_sync_message["changeOccurredTimestamp"] + assert contact_sync_message == ( + { + "integratorObjectId": str(user.id), + "action": "UPSERT", + "changeOccurredTimestamp": time, + "propertyNameToValues": serialized_user, + } + ) diff --git a/ecommerce/management/commands/configure_hubspot_bridge.py b/ecommerce/management/commands/configure_hubspot_bridge.py new file mode 100644 index 000000000..00464f687 --- /dev/null +++ b/ecommerce/management/commands/configure_hubspot_bridge.py @@ -0,0 +1,245 @@ +""" +Management command to configure the Hubspot ecommerce bridge which handles syncing Contacts, Deals, Products, +and Line Items +""" +import json +from django.core.management import BaseCommand + +from ecommerce.hubspot_api import send_hubspot_request + +# Hubspot ecommerce settings define which hubspot properties are mapped with which +# local properties when objects are synced. +# See https://developers.hubspot.com/docs/methods/ecomm-bridge/ecomm-bridge-overview for more details +HUBSPOT_ECOMMERCE_SETTINGS = { + "enabled": True, + "productSyncSettings": { + "properties": [ + { + "propertyName": "created_on", + "targetHubspotProperty": "createdate", + "dataType": "DATETIME", + }, + { + "propertyName": "title", + "targetHubspotProperty": "name", + "dataType": "STRING", + }, + { + "propertyName": "price", + "targetHubspotProperty": "price", + "dataType": "NUMBER", + }, + { + "propertyName": "description", + "targetHubspotProperty": "description", + "dataType": "STRING", + }, + ] + }, + "contactSyncSettings": { + "properties": [ + { + "propertyName": "email", + "targetHubspotProperty": "email", + "dataType": "STRING", + }, + { + "propertyName": "name", + "targetHubspotProperty": "name", + "dataType": "STRING", + }, + { + "propertyName": "first_name", + "targetHubspotProperty": "firstname", + "dataType": "STRING", + }, + { + "propertyName": "last_name", + "targetHubspotProperty": "lastname", + "dataType": "STRING", + }, + { + "propertyName": "street_address", + "targetHubspotProperty": "address", + "dataType": "STRING", + }, + { + "propertyName": "city", + "targetHubspotProperty": "city", + "dataType": "STRING", + }, + { + "propertyName": "country", + "targetHubspotProperty": "country", + "dataType": "STRING", + }, + { + "propertyName": "state_or_territory", + "targetHubspotProperty": "state", + "dataType": "STRING", + }, + { + "propertyName": "postal_code", + "targetHubspotProperty": "zip", + "dataType": "STRING", + }, + { + "propertyName": "birth_year", + "targetHubspotProperty": "birth_year", + "dataType": "STRING", + }, + { + "propertyName": "gender", + "targetHubspotProperty": "gender", + "dataType": "STRING", + }, + { + "propertyName": "company", + "targetHubspotProperty": "company", + "dataType": "STRING", + }, + { + "propertyName": "company_size", + "targetHubspotProperty": "company_size", + "dataType": "STRING", + }, + { + "propertyName": "industry", + "targetHubspotProperty": "industry", + "dataType": "STRING", + }, + { + "propertyName": "job_title", + "targetHubspotProperty": "jobtitle", + "dataType": "STRING", + }, + { + "propertyName": "job_function", + "targetHubspotProperty": "job_function", + "dataType": "STRING", + }, + { + "propertyName": "years_experience", + "targetHubspotProperty": "years_experience", + "dataType": "STRING", + }, + { + "propertyName": "leadership_level", + "targetHubspotProperty": "leadership_level", + "dataType": "STRING", + }, + ] + }, + "dealSyncSettings": { + "properties": [ + { + "propertyName": "status", + "targetHubspotProperty": "dealstage", + "dataType": "STRING", + } + ] + }, + "lineItemSyncSettings": {"properties": []}, +} + +HUBSPOT_INSTALL_PATH = "/extensions/ecomm/v1/installs" +HUBSPOT_SETTINGS_PATH = "/extensions/ecomm/v1/settings" + + +def install_hubspot_ecommerce_bridge(): + """Install the Hubspot ecommerce bridge for the api key specified in settings""" + response = send_hubspot_request("", HUBSPOT_INSTALL_PATH, "POST") + response.raise_for_status() + return response + + +def uninstall_hubspot_ecommerce_bridge(): + """Install the Hubspot ecommerce bridge for the api key specified in settings""" + response = send_hubspot_request("uninstall", HUBSPOT_INSTALL_PATH, "POST") + response.raise_for_status() + return response + + +def get_hubspot_installation_status(): + """Get the Hubspot ecommerce bridge installation status for the api key specified in settings""" + response = send_hubspot_request("status", HUBSPOT_INSTALL_PATH, "GET") + response.raise_for_status() + return response + + +def configure_hubspot_settings(): + """Configure the current Hubspot ecommerce bridge settings for the api key specified in settings""" + response = send_hubspot_request( + "", HUBSPOT_SETTINGS_PATH, "PUT", body=HUBSPOT_ECOMMERCE_SETTINGS + ) + response.raise_for_status() + return response + + +def get_hubspot_settings(): + """Get the current Hubspot ecommerce bridge settings for the api key specified in settings""" + response = send_hubspot_request("", HUBSPOT_SETTINGS_PATH, "GET") + response.raise_for_status() + return response + + +class Command(BaseCommand): + """ + Command to configure the Hubspot ecommerce bridge which will handle syncing Hubspot Products, Deals, Line Items, + and Contacts with the MITxPro Products, Orders, and Users + """ + + help = ( + "Install the Hubspot Ecommerce Bridge if it is not already installed and configure the settings based on " + "the given file. Make sure a HUBSPOT_API_KEY is set in settings and HUBSPOT_ECOMMERCE_SETTINGS are " + "configured in ecommerce/management/commands/configure_hubspot_bridge.py" + ) + + def add_arguments(self, parser): + """ + Definition of arguments this command accepts + """ + parser.add_argument( + "--uninstall", action="store_true", help="Uninstall the Ecommerce Bridge" + ) + + parser.add_argument( + "--status", + action="store_true", + help="Get the current status of the Ecommerce Bridge installation", + ) + + def handle(self, *args, **options): + print( + f"Checking Hubspot Ecommerce Bridge installation for given Hubspot API Key..." + ) + installation_status = json.loads(get_hubspot_installation_status().text) + if options["status"]: + print(f"Install completed: {installation_status['installCompleted']}") + print( + f"Ecommerce Settings enabled: {installation_status['ecommSettingsEnabled']}" + ) + if options["uninstall"]: + if installation_status["installCompleted"]: + print("Uninstalling Ecommerce Bridge...") + uninstall_hubspot_ecommerce_bridge() + print("Uninstall successful") + return + else: + print("Ecommerce Bridge is not installed") + return + if not installation_status["installCompleted"]: + print(f"Installation not found. Installing now...") + install_hubspot_ecommerce_bridge() + print("Install successful") + else: + print(f"Installation found") + if options["status"]: + print("Getting settings...") + ecommerce_bridge_settings = json.loads(get_hubspot_settings().text) + print("Found settings:") + print(ecommerce_bridge_settings) + else: + print(f"Configuring settings...") + configure_hubspot_settings() + print(f"Settings configured") diff --git a/ecommerce/tasks.py b/ecommerce/tasks.py new file mode 100644 index 000000000..9e784af28 --- /dev/null +++ b/ecommerce/tasks.py @@ -0,0 +1,46 @@ +""" +Ecommerce tasks +""" +from ecommerce.hubspot_api import ( + send_hubspot_request, + make_contact_sync_message, + make_product_sync_message, + make_deal_sync_message, + make_line_item_sync_message, +) +from mitxpro.celery import app + + +HUBSPOT_SYNC_URL = "/extensions/ecomm/v1/sync-messages" + + +@app.task +def sync_contact_with_hubspot(user_id): + """Send a sync-message to sync a user with a hubspot contact""" + body = [make_contact_sync_message(user_id)] + response = send_hubspot_request("CONTACT", HUBSPOT_SYNC_URL, "PUT", body=body) + response.raise_for_status() + + +@app.task +def sync_product_with_hubspot(product_id): + """Send a sync-message to sync a product with a hubspot product""" + body = [make_product_sync_message(product_id)] + response = send_hubspot_request("PRODUCT", HUBSPOT_SYNC_URL, "PUT", body=body) + response.raise_for_status() + + +@app.task +def sync_deal_with_hubspot(order_id): + """Send a sync-message to sync an order with a hubspot deal""" + body = [make_deal_sync_message(order_id)] + response = send_hubspot_request("DEAL", HUBSPOT_SYNC_URL, "PUT", body=body) + response.raise_for_status() + + +@app.task +def sync_line_item_with_hubspot(line_id): + """Send a sync-message to sync a line with a hubspot line item""" + body = [make_line_item_sync_message(line_id)] + response = send_hubspot_request("LINE_ITEM", HUBSPOT_SYNC_URL, "PUT", body=body) + response.raise_for_status() diff --git a/ecommerce/tasks_test.py b/ecommerce/tasks_test.py new file mode 100644 index 000000000..ce45c9126 --- /dev/null +++ b/ecommerce/tasks_test.py @@ -0,0 +1,81 @@ +""" +Tests for ecommerce tasks +""" +# pylint: disable=redefined-outer-name +from unittest.mock import ANY + +import pytest + +from faker import Faker + +from ecommerce.factories import ProductFactory, OrderFactory, LineFactory +from ecommerce.hubspot_api import ( + make_contact_sync_message, + make_product_sync_message, + make_deal_sync_message, + make_line_item_sync_message, +) +from ecommerce.tasks import ( + sync_contact_with_hubspot, + HUBSPOT_SYNC_URL, + sync_product_with_hubspot, + sync_deal_with_hubspot, + sync_line_item_with_hubspot, +) +from users.factories import UserFactory + +fake = Faker() + + +@pytest.fixture +def mock_hubspot_request(mocker): + """Mock the send hubspot request method""" + yield mocker.patch("ecommerce.tasks.send_hubspot_request") + + +@pytest.mark.django_db +def test_sync_contact_with_hubspot(mock_hubspot_request): + """Test that send_hubspot_request is called properly for a CONTACT sync""" + user = UserFactory.create() + sync_contact_with_hubspot(user.id) + body = [make_contact_sync_message(user.id)] + body[0]["changeOccurredTimestamp"] = ANY + mock_hubspot_request.assert_called_once_with( + "CONTACT", HUBSPOT_SYNC_URL, "PUT", body=body + ) + + +@pytest.mark.django_db +def test_sync_product_with_hubspot(mock_hubspot_request): + """Test that send_hubspot_request is called properly for a PRODUCT sync""" + product = ProductFactory.create() + sync_product_with_hubspot(product.id) + body = [make_product_sync_message(product.id)] + body[0]["changeOccurredTimestamp"] = ANY + mock_hubspot_request.assert_called_once_with( + "PRODUCT", HUBSPOT_SYNC_URL, "PUT", body=body + ) + + +@pytest.mark.django_db +def test_sync_deal_with_hubspot(mock_hubspot_request): + """Test that send_hubspot_request is called properly for a DEAL sync""" + order = OrderFactory.create() + sync_deal_with_hubspot(order.id) + body = [make_deal_sync_message(order.id)] + body[0]["changeOccurredTimestamp"] = ANY + mock_hubspot_request.assert_called_once_with( + "DEAL", HUBSPOT_SYNC_URL, "PUT", body=body + ) + + +@pytest.mark.django_db +def test_sync_line_item_with_hubspot(mock_hubspot_request): + """Test that send_hubspot_request is called properly for a LINE_ITEM sync""" + line = LineFactory.create() + sync_line_item_with_hubspot(line.id) + body = [make_line_item_sync_message(line.id)] + body[0]["changeOccurredTimestamp"] = ANY + mock_hubspot_request.assert_called_once_with( + "LINE_ITEM", HUBSPOT_SYNC_URL, "PUT", body=body + ) diff --git a/mitxpro/settings.py b/mitxpro/settings.py index 8442c7d38..2b7ae718d 100644 --- a/mitxpro/settings.py +++ b/mitxpro/settings.py @@ -600,3 +600,5 @@ def get_all_config_keys(): VOUCHER_INTERNATIONAL_COURSE_NUMBER_KEY = get_string( "VOUCHER_INTERNATIONAL_COURSE_NUMBER_KEY", None ) + +HUBSPOT_API_KEY = get_string("HUBSPOT_API_KEY", None) diff --git a/users/serializers.py b/users/serializers.py index 3fbecc9a9..e7db6bada 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -127,6 +127,29 @@ class Meta: } +class ProfileSerializer(serializers.ModelSerializer): + """Serializer for Profile """ + + class Meta: + model = Profile + fields = ( + "id", + "user", + "birth_year", + "gender", + "company", + "company_size", + "industry", + "job_title", + "job_function", + "years_experience", + "leadership_level", + "created_on", + "updated_on", + ) + read_only_fields = ("created_on", "updated_on") + + class PublicUserSerializer(serializers.ModelSerializer): """Serializer for public user data""" @@ -143,6 +166,7 @@ class UserSerializer(serializers.ModelSerializer): email = WriteableSerializerMethodField() username = WriteableSerializerMethodField() legal_address = LegalAddressSerializer(allow_null=True) + profile = ProfileSerializer(allow_null=True, required=False) def validate_email(self, value): """Empty validation function, but this is required for WriteableSerializerMethodField""" @@ -164,6 +188,7 @@ def get_username(self, instance): def create(self, validated_data): """Create a new user""" legal_address_data = validated_data.pop("legal_address") + profile_data = validated_data.pop("profile", None) username = validated_data.pop("username") email = validated_data.pop("email") @@ -173,7 +198,7 @@ def create(self, validated_data): username, email=email, password=password, **validated_data ) - # this side-effects such that user.legal_address is updated in-place + # this side-effects such that user.legal_address and user.profile are updated in-place if legal_address_data: legal_address = LegalAddressSerializer( user.legal_address, data=legal_address_data @@ -181,22 +206,35 @@ def create(self, validated_data): if legal_address.is_valid(): legal_address.save() + if profile_data: + profile = ProfileSerializer(user.profile, data=profile_data) + if profile.is_valid(): + profile.save() + return user @transaction.atomic def update(self, instance, validated_data): """Update an existing user""" legal_address_data = validated_data.pop("legal_address", None) + profile_data = validated_data.pop("profile", None) password = validated_data.pop("password", None) + # this side-effects such that user.legal_address and user.profile are updated in-place if legal_address_data: - # this side-effects such that instance.legal_address is updated in-place address_serializer = LegalAddressSerializer( instance.legal_address, data=legal_address_data ) if address_serializer.is_valid(raise_exception=True): address_serializer.save() + if profile_data: + profile_serializer = LegalAddressSerializer( + instance.profile, data=profile_data + ) + if profile_serializer.is_valid(raise_exception=True): + profile_serializer.save() + # save() will be called in super().update() if password is not None: instance.set_password(password) @@ -212,6 +250,7 @@ class Meta: "email", "password", "legal_address", + "profile", "is_anonymous", "is_authenticated", "created_on", @@ -226,29 +265,6 @@ class Meta: ) -class ProfileSerializer(serializers.ModelSerializer): - """Serializer for Profile """ - - class Meta: - model = Profile - fields = ( - "id", - "user", - "birth_year", - "gender", - "company", - "company_size", - "industry", - "job_title", - "job_function", - "years_experience", - "leadership_level", - "created_on", - "updated_on", - ) - read_only_fields = ("created_on", "updated_on") - - class StateProvinceSerializer(serializers.Serializer): """ Serializer for pycountry states/provinces""" diff --git a/users/views_test.py b/users/views_test.py index 614b52ed3..d5168abdd 100644 --- a/users/views_test.py +++ b/users/views_test.py @@ -55,6 +55,7 @@ def test_get_user_by_me(client, user, is_anonymous): "legal_address": None, "is_anonymous": True, "is_authenticated": False, + "profile": None, } if is_anonymous else {