From 58043dae244b41c347e1b1fa906b256b940da59f Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Mon, 13 Apr 2026 15:25:47 -0400 Subject: [PATCH 1/3] 10745 learn hubspot should be updated when user adds item to cart (#3474) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- b2b/api.py | 10 + ecommerce/api.py | 12 +- ecommerce/views/legacy/__init__.py | 18 + ecommerce/views/legacy/views_test.py | 44 +++ ecommerce/views/v0/__init__.py | 17 + hubspot_sync/api.py | 508 +++++++++++++++++++++++++++ hubspot_sync/api_test.py | 177 +++++++++- hubspot_sync/task_helpers.py | 26 ++ hubspot_sync/task_helpers_test.py | 25 ++ hubspot_sync/tasks.py | 30 ++ hubspot_sync/tasks_test.py | 16 + main/settings.py | 6 + 12 files changed, 887 insertions(+), 2 deletions(-) diff --git a/b2b/api.py b/b2b/api.py index eb80f658ca..db70495bec 100644 --- a/b2b/api.py +++ b/b2b/api.py @@ -36,6 +36,7 @@ from cms.api import get_home_page from courses.constants import UAI_COURSEWARE_ID_PREFIX from courses.models import Course, CourseRun, Department, EnrollmentMode +from courses.utils import is_uai_course_run from ecommerce.constants import ( DISCOUNT_TYPE_FIXED_PRICE, PAYMENT_TYPE_SALES, @@ -50,6 +51,7 @@ DiscountProduct, Product, ) +from hubspot_sync.task_helpers import sync_hubspot_cart_add from main import constants as main_constants from main.utils import date_to_datetime from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE @@ -1014,6 +1016,14 @@ def _prepare_basket_for_b2b_enrollment(request, product: Product) -> Basket: item = BasketItem.objects.create(product=product, basket=basket, quantity=1) item.save() + # Sync with HubSpot for CourseRun products + if isinstance(product.purchasable_object, CourseRun): + sync_hubspot_cart_add( + request.user, + product, + is_uai_course=is_uai_course_run(product.purchasable_object), + ) + return basket diff --git a/ecommerce/api.py b/ecommerce/api.py index 4f723e8d25..dc74cb22c8 100644 --- a/ecommerce/api.py +++ b/ecommerce/api.py @@ -23,6 +23,8 @@ ) from courses.api import create_run_enrollments, deactivate_run_enrollment from courses.constants import ENROLL_CHANGE_STATUS_REFUNDED +from courses.models import CourseRun +from courses.utils import is_uai_course_run from ecommerce.constants import ( ALL_DISCOUNT_TYPES, ALL_PAYMENT_TYPES, @@ -57,7 +59,7 @@ ) from ecommerce.tasks import perform_downgrade_from_order from flexiblepricing.api import determine_courseware_flexible_price_discount -from hubspot_sync.task_helpers import sync_hubspot_deal +from hubspot_sync.task_helpers import sync_hubspot_cart_add, sync_hubspot_deal from main.constants import ( USER_MSG_TYPE_B2B_ERROR_MISSING_ENROLLMENT_CODE, USER_MSG_TYPE_B2B_INVALID_BASKET, @@ -1089,6 +1091,14 @@ def create_verified_program_course_run_enrollment(request, courserun, program): redeemed_basket=basket, ) + # Sync with HubSpot for CourseRun products + if isinstance(product.purchasable_object, CourseRun): + sync_hubspot_cart_add( + request.user, + product, + is_uai_course=is_uai_course_run(product.purchasable_object), + ) + if Decimal( sum([basket_item.discounted_price for basket_item in basket.basket_items.all()]) ) > Decimal(0): diff --git a/ecommerce/views/legacy/__init__.py b/ecommerce/views/legacy/__init__.py index 6e19388fd1..bc70d74ef3 100644 --- a/ecommerce/views/legacy/__init__.py +++ b/ecommerce/views/legacy/__init__.py @@ -38,6 +38,7 @@ from rest_framework_extensions.mixins import NestedViewSetMixin from courses.models import Course, CourseRun, Program, ProgramRun +from courses.utils import is_uai_course_run from ecommerce import api from ecommerce.constants import PAYMENT_TYPE_FINANCIAL_ASSISTANCE from ecommerce.discounts import DiscountType @@ -71,6 +72,7 @@ from flexiblepricing.api import determine_courseware_flexible_price_discount from flexiblepricing.models import FlexiblePriceTier from flexiblepricing.serializers import FlexiblePriceTierSerializer +from hubspot_sync.task_helpers import sync_hubspot_cart_add from main import features from main.constants import ( USER_MSG_TYPE_BASKET_EMPTY, @@ -717,11 +719,27 @@ def add_to_cart(self, request): # Add new item to basket BasketItem.objects.create(basket=basket, product=product) message = "Product added to cart" + + # Sync with HubSpot for CourseRun products + if isinstance(product.purchasable_object, CourseRun): + sync_hubspot_cart_add( + self.request.user, + product, + is_uai_course=is_uai_course_run(product.purchasable_object), + ) else: # Legacy behavior: add single item BasketItem.objects.create(basket=basket, product=product) message = "Product added to cart" + # Sync with HubSpot for CourseRun products + if isinstance(product.purchasable_object, CourseRun): + sync_hubspot_cart_add( + self.request.user, + product, + is_uai_course=is_uai_course_run(product.purchasable_object), + ) + return Response( { "message": message, diff --git a/ecommerce/views/legacy/views_test.py b/ecommerce/views/legacy/views_test.py index 23acb469e4..a807d7fc05 100644 --- a/ecommerce/views/legacy/views_test.py +++ b/ecommerce/views/legacy/views_test.py @@ -942,6 +942,50 @@ def test_add_to_cart_api_with_feature_flag( assert basket.basket_items.first().product == product2 +def test_add_to_cart_triggers_hubspot_cart_add_for_uai_course( + user_drf_client, user, settings, mocker +): + """Adding a UAI course run product should trigger UAI-routed HubSpot cart-add tracking.""" + settings.ENABLE_MULTIPLE_CART_ITEMS = True + mock_sync = mocker.patch("ecommerce.views.legacy.sync_hubspot_cart_add") + + course_run = CourseRunFactory.create(courseware_id="course-v1:UAI_MIT+1.001x+2026") + product = ProductFactory.create(purchasable_object=course_run) + + resp = user_drf_client.post( + reverse("checkout_api-add_to_cart"), + data={"product_id": product.id}, + ) + + assert resp.status_code == status.HTTP_200_OK + mock_sync.assert_called_once_with( + user, + product, + is_uai_course=True, + ) + + +def test_add_to_cart_does_not_trigger_hubspot_for_duplicate_product( + user_drf_client, user, settings, mocker +): + """When multiple cart items are enabled, duplicate adds should not emit a second tracking event.""" + settings.ENABLE_MULTIPLE_CART_ITEMS = True + mock_sync = mocker.patch("ecommerce.views.legacy.sync_hubspot_cart_add") + + product = ProductFactory.create() + basket = BasketFactory.create(user=user) + BasketItemFactory.create(basket=basket, product=product) + + resp = user_drf_client.post( + reverse("checkout_api-add_to_cart"), + data={"product_id": product.id}, + ) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["message"] == "Product already in cart" + mock_sync.assert_not_called() + + def test_discount_rest_api(admin_drf_client, user_drf_client): """ Checks that the admin REST API is only accessible by an admin diff --git a/ecommerce/views/v0/__init__.py b/ecommerce/views/v0/__init__.py index 02321b59bd..b1ee891b2e 100644 --- a/ecommerce/views/v0/__init__.py +++ b/ecommerce/views/v0/__init__.py @@ -30,6 +30,7 @@ from rest_framework_extensions.mixins import NestedViewSetMixin from courses.models import Course, CourseRun, Program, ProgramRun +from courses.utils import is_uai_course_run from ecommerce.api import ( apply_discount_to_basket, establish_basket, @@ -68,6 +69,7 @@ ) from flexiblepricing.models import FlexiblePriceTier from flexiblepricing.serializers import FlexiblePriceTierSerializer +from hubspot_sync.task_helpers import sync_hubspot_cart_add log = logging.getLogger(__name__) User = get_user_model() @@ -229,6 +231,14 @@ def _create_basket_from_product( basket=basket, product=product, defaults={"quantity": quantity} ) + # Sync with HubSpot for CourseRun products + if isinstance(product.purchasable_object, CourseRun): + sync_hubspot_cart_add( + request.user, + product, + is_uai_course=is_uai_course_run(product.purchasable_object), + ) + existing_basket_discounts = [bd.redeemed_discount for bd in basket.discounts.all()] discounts_to_apply = [ *existing_basket_discounts, @@ -369,6 +379,13 @@ def create_basket_with_products(request): BasketItem.objects.update_or_create( basket=basket, product=product, defaults={"quantity": quantity} ) + # Sync with HubSpot for CourseRun products + if isinstance(product.purchasable_object, CourseRun): + sync_hubspot_cart_add( + request.user, + product, + is_uai_course=is_uai_course_run(product.purchasable_object), + ) except ProductBlockedError: return Response( {"error": "Product blocked from purchasing.", "product": product}, diff --git a/hubspot_sync/api.py b/hubspot_sync/api.py index eab7a156ee..8e27e73265 100644 --- a/hubspot_sync/api.py +++ b/hubspot_sync/api.py @@ -5,14 +5,19 @@ from decimal import Decimal from typing import List # noqa: UP035 +from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.db import transaction from django.db.models import Q from hubspot.crm.objects import ( SimplePublicObject, SimplePublicObjectInput, ) +from hubspot.crm.objects.models import Filter, FilterGroup, PublicObjectSearchRequest +from hubspot.crm.properties.exceptions import ApiException as PropertiesApiException from mitol.common.utils.datetime import now_in_utc from mitol.hubspot_api.api import ( + HubspotApi, HubspotAssociationType, HubspotObjectType, associate_objects_request, @@ -30,6 +35,7 @@ upsert_object_request, ) from mitol.hubspot_api.models import HubspotObject +from reversion.models import Version from courses.constants import ALL_ENROLL_CHANGE_STATUSES from courses.models import CourseRun, Program @@ -39,6 +45,7 @@ DISCOUNT_TYPE_FIXED_PRICE, DISCOUNT_TYPE_PERCENT_OFF, ) +from ecommerce.discounts import resolve_product_version from ecommerce.models import Line, Order, Product from hubspot_sync.rate_limiter import wait_for_hubspot_rate_limit from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE @@ -1378,6 +1385,507 @@ def sync_contact_with_hubspot(user: User): return result +def _get_cart_add_token(*, is_uai_course: bool) -> str: + """Resolve HubSpot token for cart-add deal tracking.""" + if is_uai_course: + return getattr(settings, "UAI_MITOL_HUBSPOT_API_PRIVATE_TOKEN", "") or getattr( + settings, "MITOL_HUBSPOT_API_PRIVATE_TOKEN", "" + ) + return getattr(settings, "MITOL_HUBSPOT_API_PRIVATE_TOKEN", "") + + +def _find_hubspot_contact_id_by_email( + hubspot_client: HubspotApi, email: str +) -> str | None: + """Find a contact id by email in the target HubSpot account.""" + wait_for_hubspot_rate_limit() + response = hubspot_client.crm.objects.search_api.do_search( + object_type=HubspotObjectType.CONTACTS.value, + public_object_search_request=PublicObjectSearchRequest( + filter_groups=[ + FilterGroup( + filters=[Filter(property_name="email", operator="EQ", value=email)] + ) + ], + properties=["email"], + limit=1, + ), + ) + if response.results: + return response.results[0].id + return None + + +def _get_target_property_options( + hubspot_client: HubspotApi, object_type: str, property_name: str +) -> list[str]: + """Return allowed option values for a HubSpot property in the target account.""" + wait_for_hubspot_rate_limit() + property_definition = hubspot_client.crm.properties.core_api.get_by_name( + object_type, + property_name, + ) + return [ + str(option.value) + for option in getattr(property_definition, "options", []) + if getattr(option, "value", None) is not None + ] + + +def _pick_preferred_option( + allowed_options: list[str], preferred_options: list[str] +) -> str | None: + """Return the first preferred option present in allowed options.""" + for preferred_option in preferred_options: + if preferred_option in allowed_options: + return preferred_option + if allowed_options: + return allowed_options[0] + return None + + +def _normalize_status_for_target( + current_status: str | None, allowed_statuses: list[str] +) -> str | None: + """Map MITx statuses to target-account options and return a valid status.""" + status_map = { + models.OrderStatus.PENDING: "created", + models.OrderStatus.FULFILLED: "fulfilled", + models.OrderStatus.CANCELED: "failed", + models.OrderStatus.DECLINED: "failed", + models.OrderStatus.ERRORED: "failed", + models.OrderStatus.REFUNDED: "refunded", + models.OrderStatus.PARTIALLY_REFUNDED: "refunded", + models.OrderStatus.REVIEW: "created", + } + mapped_status = status_map.get(current_status or "", current_status) + if mapped_status in allowed_statuses: + return mapped_status + return _pick_preferred_option( + allowed_statuses, + ["created", "checkout_pending", "fulfilled", "failed", "refunded"], + ) + + +def _get_target_pipeline_stage_map(hubspot_client: HubspotApi) -> dict[str, list[str]]: + """Return mapping of deal pipeline id to valid stage ids in target account.""" + wait_for_hubspot_rate_limit() + pipelines_response = hubspot_client.crm.pipelines.pipelines_api.get_all( + object_type=HubspotObjectType.DEALS.value + ) + pipeline_stage_map = {} + for pipeline in getattr(pipelines_response, "results", []): + pipeline_id = str(getattr(pipeline, "id", "") or "") + if not pipeline_id: + continue + stages = [ + str(getattr(stage, "id", "")) + for stage in getattr(pipeline, "stages", []) + if getattr(stage, "id", None) is not None + ] + if stages: + pipeline_stage_map[pipeline_id] = stages + return pipeline_stage_map + + +def _normalize_deal_properties_for_target_account( # noqa: C901 + hubspot_client: HubspotApi, deal_input: SimplePublicObjectInput +) -> None: + """Normalize dealstage and status so they are valid in the target HubSpot account.""" + deal_properties = deal_input.properties + + legacy_stage_map = { + "48288379": "checkout_abandoned", + "48288388": "checkout_pending", + "48288389": "checkout_completed", + "48288390": "processed", + } + + current_pipeline = str(deal_properties.get("pipeline") or "") + try: + pipeline_stage_map = _get_target_pipeline_stage_map(hubspot_client) + except Exception: # noqa: BLE001 + pipeline_stage_map = {} + + if pipeline_stage_map: + if current_pipeline not in pipeline_stage_map: + resolved_pipeline = _pick_preferred_option( + list(pipeline_stage_map.keys()), + [str(getattr(settings, "HUBSPOT_PIPELINE_ID", "")), "default"], + ) + if resolved_pipeline: + deal_properties["pipeline"] = resolved_pipeline + current_pipeline = resolved_pipeline + + allowed_stages = pipeline_stage_map.get(current_pipeline, []) + else: + try: + allowed_stages = _get_target_property_options( + hubspot_client, HubspotObjectType.DEALS.value, "dealstage" + ) + except PropertiesApiException: + allowed_stages = [] + + current_stage = deal_properties.get("dealstage") + mapped_legacy_stage = legacy_stage_map.get(str(current_stage)) + if mapped_legacy_stage: + deal_properties["dealstage"] = mapped_legacy_stage + current_stage = mapped_legacy_stage + + if allowed_stages and current_stage not in allowed_stages: + resolved_stage = _pick_preferred_option( + allowed_stages, + [ + "checkout_pending", + "created", + "appointmentscheduled", + "qualifiedtobuy", + ], + ) + if resolved_stage: + deal_properties["dealstage"] = resolved_stage + log.info( + "Normalized dealstage for target account from %s to %s", + current_stage, + resolved_stage, + ) + + try: + allowed_statuses = _get_target_property_options( + hubspot_client, HubspotObjectType.DEALS.value, "status" + ) + except PropertiesApiException: + allowed_statuses = [] + + if allowed_statuses: + resolved_status = _normalize_status_for_target( + deal_properties.get("status"), + allowed_statuses, + ) + if resolved_status: + deal_properties["status"] = resolved_status + + +def _normalize_line_item_properties_for_target_account( + hubspot_client: HubspotApi, line_item_input: SimplePublicObjectInput +) -> None: + """Normalize line-item status so it is valid in the target HubSpot account.""" + line_item_properties = line_item_input.properties + + try: + allowed_statuses = _get_target_property_options( + hubspot_client, HubspotObjectType.LINES.value, "status" + ) + except PropertiesApiException: + allowed_statuses = [] + + if allowed_statuses: + resolved_status = _normalize_status_for_target( + line_item_properties.get("status"), + allowed_statuses, + ) + if resolved_status: + line_item_properties["status"] = resolved_status + + +def _ensure_target_hubspot_custom_properties(hubspot_client: HubspotApi) -> None: + """Ensure custom MITx e-commerce properties and groups exist in the target account.""" + object_configs = { + object_type: { + "groups": list(config["groups"]), + "properties": list(config["properties"]), + } + for object_type, config in CUSTOM_ECOMMERCE_PROPERTIES.items() + } + object_configs[HubspotObjectType.CONTACTS.value]["properties"].extend( + [ + _get_course_run_certificate_hubspot_property(), + _get_program_certificate_hubspot_property(), + ] + ) + + for object_type, config in object_configs.items(): + wait_for_hubspot_rate_limit() + existing_groups = hubspot_client.crm.properties.groups_api.get_all(object_type) + existing_group_names = { + group.name for group in getattr(existing_groups, "results", []) + } + + for group in config["groups"]: + wait_for_hubspot_rate_limit() + try: + if group["name"] in existing_group_names: + hubspot_client.crm.properties.groups_api.update( + object_type, group["name"], group + ) + else: + hubspot_client.crm.properties.groups_api.create(object_type, group) + except PropertiesApiException: + log.exception( + "Failed syncing HubSpot property group %s for %s in target account", + group["name"], + object_type, + ) + raise + + wait_for_hubspot_rate_limit() + existing_properties = hubspot_client.crm.properties.core_api.get_all( + object_type + ) + existing_property_names = { + prop.name for prop in getattr(existing_properties, "results", []) + } + + for prop in config["properties"]: + wait_for_hubspot_rate_limit() + try: + if prop["name"] in existing_property_names: + # HubSpot-managed unique_app_id definitions are read-only in some accounts. + if prop["name"] == "unique_app_id": + continue + hubspot_client.crm.properties.core_api.update( + object_type, + prop["name"], + prop, + ) + else: + hubspot_client.crm.properties.core_api.create(object_type, prop) + except PropertiesApiException: + log.exception( + "Failed syncing HubSpot property %s for %s in target account", + prop["name"], + object_type, + ) + raise + + +def _build_target_deal_message( + order: Order, hubspot_client: HubspotApi +) -> SimplePublicObjectInput: + """Create a deal message normalized for target-account property options.""" + deal_input = make_deal_sync_message_from_order(order) + _normalize_deal_properties_for_target_account(hubspot_client, deal_input) + return deal_input + + +def _build_target_line_item_message( + line: Line, hubspot_client: HubspotApi +) -> SimplePublicObjectInput: + """Create a line-item message normalized for target-account property options.""" + line_item_input = make_line_item_sync_message_from_line(line) + target_product_id = _ensure_target_hubspot_product_for_line(line, hubspot_client) + if target_product_id: + line_item_input.properties["hs_product_id"] = target_product_id + _normalize_line_item_properties_for_target_account(hubspot_client, line_item_input) + return line_item_input + + +def _get_product_from_line(line: Line) -> Product | None: + """Resolve the line's product similarly to serializer logic used for HubSpot payloads.""" + if not line.product_version: + return None + version = line.product_version + product = Product.all_objects.filter(id=version.object_id).first() + if product: + return resolve_product_version(product, product_version=version) + return version.object + + +def _find_target_product_id_by_unique_app_id( + hubspot_client: HubspotApi, unique_app_id: str +) -> str | None: + """Find product id in target account by unique_app_id.""" + wait_for_hubspot_rate_limit() + response = hubspot_client.crm.objects.search_api.do_search( + object_type=HubspotObjectType.PRODUCTS.value, + public_object_search_request=PublicObjectSearchRequest( + filter_groups=[ + FilterGroup( + filters=[ + Filter( + property_name="unique_app_id", + operator="EQ", + value=unique_app_id, + ) + ] + ) + ], + properties=["unique_app_id"], + limit=1, + ), + ) + if response.results: + return response.results[0].id + return None + + +def _ensure_target_hubspot_product_for_line( + line: Line, hubspot_client: HubspotApi +) -> str | None: + """Return a target-account product id for a line item's hs_product_id.""" + product = _get_product_from_line(line) + if not product: + return None + + product_input = make_product_sync_message_from_product(product) + unique_app_id = str(product_input.properties.get("unique_app_id") or "") + if unique_app_id: + existing_product_id = _find_target_product_id_by_unique_app_id( + hubspot_client, unique_app_id + ) + if existing_product_id: + return existing_product_id + + wait_for_hubspot_rate_limit() + created_product = hubspot_client.crm.objects.basic_api.create( + object_type=HubspotObjectType.PRODUCTS.value, + simple_public_object_input_for_create=product_input, + ) + return created_product.id + + +def _ensure_target_hubspot_contact_properties(hubspot_client: HubspotApi) -> None: + """Backward-compatible wrapper retained for tests/callers.""" + _ensure_target_hubspot_custom_properties(hubspot_client) + + +def _ensure_hubspot_contact_for_user( + user: User, hubspot_client: HubspotApi +) -> str | None: + """Return target-account contact id, creating a contact when missing.""" + contact_id = _find_hubspot_contact_id_by_email(hubspot_client, user.email) + if contact_id: + log.info( + "Found existing HubSpot contact in target account for user_id=%s email=%s", + user.id, + user.email, + ) + return contact_id + + wait_for_hubspot_rate_limit() + contact = hubspot_client.crm.objects.basic_api.create( + object_type=HubspotObjectType.CONTACTS.value, + simple_public_object_input_for_create=make_contact_sync_message_from_user(user), + ) + user.hubspot_sync_datetime = now_in_utc() + user.save(update_fields=["hubspot_sync_datetime"]) + return contact.id + + +def _sync_cart_add_deal_with_hubspot( + order: Order, contact_id: str, hubspot_client: HubspotApi +) -> SimplePublicObject: + """Create cart-add deal and line-item objects and associate them in target account.""" + deal_input = _build_target_deal_message(order, hubspot_client) + + wait_for_hubspot_rate_limit() + deal = hubspot_client.crm.objects.basic_api.create( + object_type=HubspotObjectType.DEALS.value, + simple_public_object_input_for_create=deal_input, + ) + + wait_for_hubspot_rate_limit() + hubspot_client.crm.associations.v4.basic_api.create_default( + from_object_type=HubspotObjectType.DEALS.value, + from_object_id=deal.id, + to_object_type=HubspotObjectType.CONTACTS.value, + to_object_id=contact_id, + ) + + for line in order.lines.all(): + line_item_input = _build_target_line_item_message(line, hubspot_client) + + wait_for_hubspot_rate_limit() + line_item = hubspot_client.crm.objects.basic_api.create( + object_type=HubspotObjectType.LINES.value, + simple_public_object_input_for_create=line_item_input, + ) + + wait_for_hubspot_rate_limit() + hubspot_client.crm.associations.v4.basic_api.create_default( + from_object_type=HubspotObjectType.LINES.value, + from_object_id=line_item.id, + to_object_type=HubspotObjectType.DEALS.value, + to_object_id=deal.id, + ) + + return deal + + +def track_cart_add_with_hubspot( + user: User, product: Product, *, is_uai_course: bool +) -> bool: + """ + Create and sync a dedicated deal that represents a cart-add occurrence. + + This mirrors the existing deal sync path used by ecommerce orders but creates + a standalone pending order/line so each cart add is a distinct deal. + + Args: + user (User): The user adding to cart + product (Product): Product being added + is_uai_course (bool): Whether this is a UAI/Learn course add + + Returns: + bool: True if synced successfully, False otherwise. + """ + token = _get_cart_add_token(is_uai_course=is_uai_course) + if not token: + return False + + try: + hubspot_client = HubspotApi(access_token=token) + _ensure_target_hubspot_contact_properties(hubspot_client) + + # UAI deals must have a contact in the same HubSpot account. + contact_id = _ensure_hubspot_contact_for_user(user, hubspot_client) + if not contact_id: + return False + + product_version = Version.objects.get_for_object(product).first() + if not product_version: + log.info( + "No version found for product_id=%s; cannot sync cart-add deal", + product.id, + ) + return False + + with transaction.atomic(): + order = Order.objects.create( + state=models.OrderStatus.PENDING, + purchaser=user, + total_price_paid=0, + ) + line = Line.objects.create( + order=order, + purchased_object_id=product.object_id, + purchased_content_type_id=product.content_type_id, + product_version=product_version, + quantity=1, + ) + order.total_price_paid = line.discounted_price + order.save(update_fields=["total_price_paid"]) + + deal = _sync_cart_add_deal_with_hubspot(order, contact_id, hubspot_client) + log.info( + "Synced cart-add deal with HubSpot for user_id=%s product_id=%s deal_id=%s is_uai=%s", + user.id, + product.id, + deal.id, + is_uai_course, + ) + except Exception: # pylint: disable=broad-except + log.exception( + "Failed to sync HubSpot cart-add deal for user %s product %s (is_uai=%s)", + user.id, + product.id, + is_uai_course, + ) + return False + + return True + + MODEL_CREATE_FUNCTION_MAPPING = { "user": make_contact_create_message_list_from_user_ids, "order": make_deal_create_message_list_from_order_ids, diff --git a/hubspot_sync/api_test.py b/hubspot_sync/api_test.py index f3cea16bd1..20d8886354 100644 --- a/hubspot_sync/api_test.py +++ b/hubspot_sync/api_test.py @@ -1,11 +1,12 @@ """Tests for hubspot_sync.api""" import json +from types import SimpleNamespace import pytest import reversion from django.contrib.contenttypes.models import ContentType -from hubspot.crm.objects import ApiException +from hubspot.crm.objects import ApiException, SimplePublicObjectInput from mitol.common.utils.datetime import now_in_utc from mitol.hubspot_api.factories import HubspotObjectFactory, SimplePublicObjectFactory from mitol.hubspot_api.models import HubspotObject @@ -15,6 +16,7 @@ from courses.factories import ( CourseRunCertificateFactory, CourseRunEnrollmentFactory, + CourseRunFactory, ProgramCertificateFactory, ) from ecommerce.factories import LineFactory, OrderFactory, ProductFactory @@ -289,6 +291,29 @@ def test_sync_product_with_hubspot(mock_hubspot_api): ) +def test_ensure_target_hubspot_custom_properties_skips_unique_app_id_update(mocker): + """Existing unique_app_id properties should not be updated because they can be read-only.""" + mock_client = mocker.Mock() + mock_client.crm.properties.groups_api.get_all.return_value = SimpleNamespace( + results=[] + ) + mock_client.crm.properties.core_api.get_all.return_value = SimpleNamespace( + results=[ + SimpleNamespace(name="status"), + SimpleNamespace(name="unique_app_id"), + ] + ) + + api._ensure_target_hubspot_custom_properties(mock_client) # noqa: SLF001 + + updated_property_names = { + call.args[1] + for call in mock_client.crm.properties.core_api.update.call_args_list + } + assert "status" in updated_property_names + assert "unique_app_id" not in updated_property_names + + def test_sync_deal_with_hubspot(mocker, mock_hubspot_api, hubspot_order): """Test that the hubspot CRM API is called properly for a deal sync""" mock_sync_line = mocker.patch( @@ -538,3 +563,153 @@ def test_get_hubspot_id_raises(mocker, user): get_hubspot_id_for_object(user, raise_error=True) mock_log.assert_called_once() assert f"Hubspot id could not be found for user for id {user.id}" == str(exc.value) + + +def test_track_cart_add_with_hubspot_uses_uai_account(settings, mocker, user): + """UAI course adds should route using UAI HubSpot token when available.""" + settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN = "mitx-token" # noqa: S105 + settings.UAI_MITOL_HUBSPOT_API_PRIVATE_TOKEN = "uai-token" # noqa: S105 + + course_run = CourseRunFactory.create(courseware_id="course-v1:UAI_MIT+1.001x+2026") + product = ProductFactory.create(purchasable_object=course_run) + with reversion.create_revision(): + product.save() + + mock_client = mocker.patch("hubspot_sync.api.HubspotApi") + mocker.patch( + "hubspot_sync.api._ensure_target_hubspot_contact_properties", + ) + mocker.patch( + "hubspot_sync.api._ensure_hubspot_contact_for_user", return_value="contact-id" + ) + mock_sync_deal = mocker.patch("hubspot_sync.api._sync_cart_add_deal_with_hubspot") + + assert api.track_cart_add_with_hubspot(user, product, is_uai_course=True) is True + mock_client.assert_called_once_with(access_token="uai-token") # noqa: S106 + mock_sync_deal.assert_called_once() + + +def test_track_cart_add_with_hubspot_syncs_missing_contact(settings, mocker, user): + """Missing contacts should be synced before creating a cart-add deal.""" + settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN = "mitx-token" # noqa: S105 + settings.UAI_MITOL_HUBSPOT_API_PRIVATE_TOKEN = "uai-token" # noqa: S105 + + product = ProductFactory.create() + with reversion.create_revision(): + product.save() + + mock_client = mocker.patch("hubspot_sync.api.HubspotApi") + mock_ensure_props = mocker.patch( + "hubspot_sync.api._ensure_target_hubspot_contact_properties", + ) + mock_ensure_contact = mocker.patch( + "hubspot_sync.api._ensure_hubspot_contact_for_user", return_value="contact-id" + ) + mock_sync_deal = mocker.patch("hubspot_sync.api._sync_cart_add_deal_with_hubspot") + + assert api.track_cart_add_with_hubspot(user, product, is_uai_course=True) is True + mock_ensure_props.assert_called_once_with(mock_client.return_value) + mock_ensure_contact.assert_called_once_with(user, mock_client.return_value) + mock_sync_deal.assert_called_once() + + +def test_track_cart_add_with_hubspot_returns_false_when_contact_sync_fails( + settings, mocker, user +): + """Deal sync should be skipped when contact sync in target account fails.""" + settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN = "mitx-token" # noqa: S105 + settings.UAI_MITOL_HUBSPOT_API_PRIVATE_TOKEN = "uai-token" # noqa: S105 + + product = ProductFactory.create() + with reversion.create_revision(): + product.save() + + mocker.patch( + "hubspot_sync.api._ensure_target_hubspot_contact_properties", + ) + mock_sync_deal = mocker.patch("hubspot_sync.api._sync_cart_add_deal_with_hubspot") + mocker.patch( + "hubspot_sync.api._ensure_hubspot_contact_for_user", + return_value=None, + ) + + assert api.track_cart_add_with_hubspot(user, product, is_uai_course=True) is False + mock_sync_deal.assert_not_called() + + +def test_track_cart_add_with_hubspot_returns_false_when_unconfigured(settings, user): + """Tracking should be skipped when no HubSpot token is configured.""" + settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN = "" + settings.UAI_MITOL_HUBSPOT_API_PRIVATE_TOKEN = "" + + product = ProductFactory.create() + assert api.track_cart_add_with_hubspot(user, product, is_uai_course=False) is False + + +def test_normalize_deal_properties_for_target_account_pipeline_stage_mismatch(mocker): + """Dealstage should be normalized to one allowed by the selected pipeline.""" + mock_client = mocker.Mock() + mocker.patch( + "hubspot_sync.api._get_target_pipeline_stage_map", + return_value={ + "19817792": ["created", "processed"], + "default": ["checkout_pending"], + }, + ) + mocker.patch( + "hubspot_sync.api._get_target_property_options", + return_value=["created", "fulfilled", "failed", "refunded"], + ) + + deal_input = SimplePublicObjectInput( + properties={ + "pipeline": "19817792", + "dealstage": "checkout_pending", + "status": "pending", + } + ) + + api._normalize_deal_properties_for_target_account(mock_client, deal_input) # noqa: SLF001 + + assert deal_input.properties["pipeline"] == "19817792" + assert deal_input.properties["dealstage"] == "created" + + +def test_build_target_line_item_message_uses_target_product_id_from_search( + mocker, hubspot_order +): + """Line item hs_product_id should use target-account product id when found.""" + line = hubspot_order.lines.first() + mock_client = mocker.Mock() + + mocker.patch( + "hubspot_sync.api._ensure_target_hubspot_product_for_line", + return_value="target-product-123", + ) + mocker.patch( + "hubspot_sync.api._normalize_line_item_properties_for_target_account", + ) + + message = api._build_target_line_item_message(line, mock_client) # noqa: SLF001 + + assert message.properties["hs_product_id"] == "target-product-123" + + +def test_ensure_target_hubspot_product_for_line_creates_when_missing( + mocker, hubspot_order +): + """A target product should be created when unique_app_id lookup misses.""" + line = hubspot_order.lines.first() + mock_client = mocker.Mock() + mock_client.crm.objects.basic_api.create.return_value = SimpleNamespace( + id="created-product-999" + ) + + mocker.patch( + "hubspot_sync.api._find_target_product_id_by_unique_app_id", return_value=None + ) + + result = api._ensure_target_hubspot_product_for_line(line, mock_client) # noqa: SLF001 + + assert result == "created-product-999" + mock_client.crm.objects.basic_api.create.assert_called_once() diff --git a/hubspot_sync/task_helpers.py b/hubspot_sync/task_helpers.py index faee005c8f..7de7170537 100644 --- a/hubspot_sync/task_helpers.py +++ b/hubspot_sync/task_helpers.py @@ -78,3 +78,29 @@ def sync_hubspot_product(product: Product): log.exception( "Exception calling sync_product_with_hubspot for product %d", product.id ) + + +def sync_hubspot_cart_add(user: User, product: Product, *, is_uai_course: bool): + """ + Trigger celery task to track a cart add event in HubSpot. + + Args: + user (User): The user adding the product to cart + product (Product): The product being added + is_uai_course (bool): Whether the added course is a UAI course + """ + if settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN or getattr( + settings, "UAI_MITOL_HUBSPOT_API_PRIVATE_TOKEN", None + ): + try: + tasks.sync_cart_add_event_with_hubspot.apply_async( + args=(user.id, product.id), + kwargs={"is_uai_course": is_uai_course}, + countdown=5, + ) + except: # noqa: E722 + log.exception( + "Exception calling sync_cart_add_event_with_hubspot for user %s and product %d", + user.edx_username, + product.id, + ) diff --git a/hubspot_sync/task_helpers_test.py b/hubspot_sync/task_helpers_test.py index ecfe84bb6d..dc23a92f6f 100644 --- a/hubspot_sync/task_helpers_test.py +++ b/hubspot_sync/task_helpers_test.py @@ -4,6 +4,7 @@ from ecommerce.factories import ProductFactory from hubspot_sync.task_helpers import ( + sync_hubspot_cart_add, sync_hubspot_deal, sync_hubspot_product, sync_hubspot_user, @@ -69,3 +70,27 @@ def test_sync_hubspot_product(mocker, mock_exception_log, raise_exc): ) else: mock_exception_log.assert_not_called() + + +@pytest.mark.parametrize("raise_exc", [True, False]) +def test_sync_hubspot_cart_add(mocker, mock_exception_log, user, raise_exc): + """sync_hubspot_cart_add should call sync_cart_add_event_with_hubspot.apply_async and log any exception""" + mock_sync = mocker.patch( + "hubspot_sync.task_helpers.tasks.sync_cart_add_event_with_hubspot.apply_async", + side_effect=(ConnectionError if raise_exc else None), + ) + product = ProductFactory.build() + sync_hubspot_cart_add(user, product, is_uai_course=True) + mock_sync.assert_called_once_with( + args=(user.id, product.id), + kwargs={"is_uai_course": True}, + countdown=5, + ) + if raise_exc: + mock_exception_log.assert_called_once_with( + "Exception calling sync_cart_add_event_with_hubspot for user %s and product %d", + user.edx_username, + product.id, + ) + else: + mock_exception_log.assert_not_called() diff --git a/hubspot_sync/tasks.py b/hubspot_sync/tasks.py index af2366a11a..ce68ab3b91 100644 --- a/hubspot_sync/tasks.py +++ b/hubspot_sync/tasks.py @@ -224,6 +224,36 @@ def sync_line_with_hubspot(line_id: int) -> str: return api.sync_line_item_with_hubspot(Line.objects.get(id=line_id)).id +@app.task( + acks_late=True, + autoretry_for=(TooManyRequestsException, BlockingIOError), + max_retries=3, + retry_backoff=60, + retry_jitter=True, +) +@raise_429 +@single_task(10, key=task_obj_lock) +def sync_cart_add_event_with_hubspot( + user_id: int, product_id: int, *, is_uai_course: bool +) -> bool: + """ + Track a cart add event in HubSpot for a user/product pair. + + Args: + user_id (int): The User ID. + product_id (int): The Product ID. + is_uai_course (bool): Whether the product's course run is UAI. + + Returns: + bool: True if the event was submitted, False otherwise. + """ + return api.track_cart_add_with_hubspot( + User.objects.get(id=user_id), + Product.objects.get(id=product_id), + is_uai_course=is_uai_course, + ) + + @app.task( acks_late=True, autoretry_for=(TooManyRequestsException,), diff --git a/hubspot_sync/tasks_test.py b/hubspot_sync/tasks_test.py index 2140a64bdf..98ac567a0b 100644 --- a/hubspot_sync/tasks_test.py +++ b/hubspot_sync/tasks_test.py @@ -31,6 +31,7 @@ from hubspot_sync.tasks import ( batch_upsert_associations, batch_upsert_associations_chunked, + sync_cart_add_event_with_hubspot, sync_contact_with_hubspot, sync_deal_with_hubspot, sync_product_with_hubspot, @@ -87,6 +88,21 @@ def test_task_sync_deal_with_hubspot(mocker): mock_api_call.assert_called_once_with(mock_object) +def test_task_sync_cart_add_event_with_hubspot(mocker): + """sync_cart_add_event_with_hubspot should call API tracker and return success state.""" + user = UserFactory.create() + product = ProductFactory.create() + mock_api_call = mocker.patch( + "hubspot_sync.tasks.api.track_cart_add_with_hubspot", return_value=True + ) + + assert ( + sync_cart_add_event_with_hubspot(user.id, product.id, is_uai_course=True) + is True + ) + mock_api_call.assert_called_once_with(user, product, is_uai_course=True) + + @pytest.mark.parametrize("task_func", SYNC_FUNCTIONS) @pytest.mark.parametrize( "status, expected_error", # noqa: PT006 diff --git a/main/settings.py b/main/settings.py index 6e51df9c8f..40f8da8486 100644 --- a/main/settings.py +++ b/main/settings.py @@ -1381,6 +1381,12 @@ description="Hubspot Portal ID", ) +UAI_MITOL_HUBSPOT_API_PRIVATE_TOKEN = get_string( + name="UAI_MITOL_HUBSPOT_API_PRIVATE_TOKEN", + default=None, + description="Hubspot private token for UAI/Learn account", +) + # Unified Ecommerce integration UNIFIED_ECOMMERCE_URL = get_string( From 711f53a12ea9422d413e35f1774235c40d052851 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Tue, 14 Apr 2026 09:27:47 -0500 Subject: [PATCH 2/3] Add soft delete to the partner school model (#3485) --- courses/admin.py | 22 +++++++++++++++++++ ...0091_add_soft_delete_to_partner_schools.py | 17 ++++++++++++++ courses/models.py | 20 +++++++++++++++++ openapi/specs/v0.yaml | 4 ++++ openapi/specs/v1.yaml | 4 ++++ openapi/specs/v2.yaml | 4 ++++ 6 files changed, 71 insertions(+) create mode 100644 courses/migrations/0091_add_soft_delete_to_partner_schools.py diff --git a/courses/admin.py b/courses/admin.py index d03c9af0cb..039d843393 100644 --- a/courses/admin.py +++ b/courses/admin.py @@ -711,6 +711,22 @@ class PartnerSchoolAdmin(TimestampedModelAdmin): list_display = ["name", "email"] search_fields = ["name", "email"] + def get_queryset(self, request): # noqa: ARG002 + """Use the all_objects manager so we can see everything.""" + + return self.model.all_objects.get_queryset() + + def delete_model(self, request, obj): # noqa: ARG002 + """Soft-delete the model.""" + + obj.is_active = False + obj.save() + + def delete_queryset(self, request, queryset): # noqa: ARG002 + """Soft-delete using the queryset.""" + + queryset.update(is_active=False) + @admin.register(LearnerProgramRecordShare) class LearnerProgramRecordShareAdmin(TimestampedModelAdmin): @@ -719,6 +735,12 @@ class LearnerProgramRecordShareAdmin(TimestampedModelAdmin): model = LearnerProgramRecordShare list_display = ["share_uuid", "user", "partner_school", "is_active"] search_fields = ["share_uuid"] + readonly_fields = [ + "share_uuid", + "user", + "partner_school", + "program", + ] @admin.register(RelatedProgram) diff --git a/courses/migrations/0091_add_soft_delete_to_partner_schools.py b/courses/migrations/0091_add_soft_delete_to_partner_schools.py new file mode 100644 index 0000000000..f6378f430e --- /dev/null +++ b/courses/migrations/0091_add_soft_delete_to_partner_schools.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.15 on 2026-04-13 20:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0090_alter_program_departments"), + ] + + operations = [ + migrations.AddField( + model_name="partnerschool", + name="is_active", + field=models.BooleanField(blank=True, default=True), + ), + ] diff --git a/courses/models.py b/courses/models.py index f1bfe03107..c4dc08cb91 100644 --- a/courses/models.py +++ b/courses/models.py @@ -2375,6 +2375,15 @@ class Meta: ] +class PartnerSchoolActiveUndeleteManager(models.Manager): + """Query manager for active objects""" + + # This can be used generally, for the models that have `is_active` field + def get_queryset(self): + """Getting the active queryset for manager""" + return super().get_queryset().filter(is_active=True) + + class PartnerSchool(TimestampedModel): """ Model for partner school to send records to (copied from MicroMasters) @@ -2382,10 +2391,21 @@ class PartnerSchool(TimestampedModel): name = models.CharField(max_length=255) email = models.TextField(null=False) + is_active = models.BooleanField(default=True, blank=True) + + objects = PartnerSchoolActiveUndeleteManager() + all_objects = models.Manager() def __str__(self): return self.name + def delete(self, *, using=None, keep_parents=False): # noqa: ARG002 + """Soft-delete the record.""" + + self.is_active = False + self.save(update_fields=("is_active",)) + return (1, {"courses.PartnerSchool": 1}) + class LearnerProgramRecordShare(TimestampedModel): """ diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index a4d6fde8ca..630a8a0996 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -6442,6 +6442,8 @@ components: maxLength: 255 email: type: string + is_active: + type: boolean required: - created_on - email @@ -6458,6 +6460,8 @@ components: email: type: string minLength: 1 + is_active: + type: boolean required: - email - name diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 59cf4312df..4621759858 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -6442,6 +6442,8 @@ components: maxLength: 255 email: type: string + is_active: + type: boolean required: - created_on - email @@ -6458,6 +6460,8 @@ components: email: type: string minLength: 1 + is_active: + type: boolean required: - email - name diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index cc8258b3f4..01570bb96e 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -6442,6 +6442,8 @@ components: maxLength: 255 email: type: string + is_active: + type: boolean required: - created_on - email @@ -6458,6 +6460,8 @@ components: email: type: string minLength: 1 + is_active: + type: boolean required: - email - name From d8fcecd3edc5c263d1ac03032aede3339d633573 Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 14 Apr 2026 21:12:17 +0000 Subject: [PATCH 3/3] Release 1.146.3 --- RELEASE.rst | 6 ++++++ main/settings.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index aba7e97aab..6bf0c501f0 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 1.146.3 +--------------- + +- Add soft delete to the partner school model (#3485) +- 10745 learn hubspot should be updated when user adds item to cart (#3474) + Version 1.146.2 (Released April 13, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index 7615a3ffb3..53daca35e0 100644 --- a/main/settings.py +++ b/main/settings.py @@ -37,7 +37,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.146.2" +VERSION = "1.146.3" log = logging.getLogger()