From db5a8c3de75692226718eded068770ddb0f9a7ca Mon Sep 17 00:00:00 2001 From: CP Date: Tue, 11 Aug 2026 12:47:57 -0400 Subject: [PATCH 1/3] Skip HubSpot deal sync for program-enrolled courses Add a check in sync_hubspot_deal to skip syncing orders where the purchaser is already enrolled in a program containing the purchased course run. This prevents duplicate/misleading deals in HubSpot when a user buys an individual course that's part of a program they're already enrolled in. --- hubspot_sync/task_helpers.py | 24 +++++++ hubspot_sync/task_helpers_test.py | 100 +++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/hubspot_sync/task_helpers.py b/hubspot_sync/task_helpers.py index 25d574d830..5915bcfe0c 100644 --- a/hubspot_sync/task_helpers.py +++ b/hubspot_sync/task_helpers.py @@ -4,6 +4,7 @@ from django.conf import settings +from courses.models import CourseRun, ProgramEnrollment from courses.utils import is_uai_order from ecommerce.models import Order, Product from hubspot_sync import tasks @@ -41,6 +42,20 @@ def sync_hubspot_user(user: User): ) +def _order_is_for_program_enrolled_course(order: Order) -> bool: + """Return True if any line is a course run belonging to a program the purchaser is already enrolled in.""" + for line in order.lines.all(): + purchased_object = line.purchased_object + if not purchased_object: + continue + if isinstance(purchased_object, CourseRun) and ProgramEnrollment.objects.filter( + user=order.purchaser, + program__all_requirements__course=purchased_object.course, + ).exists(): + return True + return False + + def sync_hubspot_deal(order: Order): """ Trigger celery task to sync an order to Hubspot if it has lines. @@ -60,6 +75,15 @@ def sync_hubspot_deal(order: Order): ) return + if _order_is_for_program_enrolled_course(order): + log.info( + "Skipping HubSpot deal sync for order %d: user %s (user_id=%d) is already enrolled in a program containing this course", + order.id, + order.purchaser.edx_username or order.purchaser.email, + order.purchaser.id, + ) + return + if order.lines.first() is not None: is_uai = is_uai_order(order) diff --git a/hubspot_sync/task_helpers_test.py b/hubspot_sync/task_helpers_test.py index d71426aa03..340232477f 100644 --- a/hubspot_sync/task_helpers_test.py +++ b/hubspot_sync/task_helpers_test.py @@ -1,9 +1,13 @@ """Tests for hubspot_sync.task_helpers""" import pytest +import reversion +from reversion.models import Version from b2b.factories import ContractPageFactory -from ecommerce.factories import ProductFactory +from courses.factories import CourseRunFactory, ProgramEnrollmentFactory, ProgramFactory +from courses.models import ProgramRequirementNodeType +from ecommerce.factories import LineFactory, OrderFactory, ProductFactory from hubspot_sync.task_helpers import ( sync_hubspot_cart_add, sync_hubspot_deal, @@ -172,6 +176,100 @@ def test_sync_hubspot_product(mocker, mock_exception_log, raise_exc): mock_exception_log.assert_not_called() +def test_sync_hubspot_deal_skips_for_course_in_enrolled_program(mocker, settings): + """sync_hubspot_deal should skip if the order is for a course run in a program the user is enrolled in.""" + settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN = "faketoken" # noqa: S105 + + mock_sync = mocker.patch( + "hubspot_sync.task_helpers.tasks.sync_deal_with_hubspot_targeted.apply_async" + ) + mocker.patch("hubspot_sync.task_helpers.is_uai_order", return_value=False) + + course_run = CourseRunFactory.create() + program = ProgramFactory.create() + program.requirements_root.add_child( + node_type=ProgramRequirementNodeType.COURSE, + course=course_run.course, + ) + + with reversion.create_revision(): + product = ProductFactory.create(purchasable_object=course_run) + + order = OrderFactory.create() + LineFactory.create( + order=order, + product_version=Version.objects.get_for_object(product).first(), + purchased_object=course_run, + ) + ProgramEnrollmentFactory.create(user=order.purchaser, program=program) + + sync_hubspot_deal(order) + + mock_sync.assert_not_called() + + +def test_sync_hubspot_deal_proceeds_for_course_not_in_program( + mocker, mock_exception_log, settings +): + """sync_hubspot_deal should proceed if the order is for a course not in any program.""" + settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN = "faketoken" # noqa: S105 + + mock_sync = mocker.patch( + "hubspot_sync.task_helpers.tasks.sync_deal_with_hubspot_targeted.apply_async" + ) + mocker.patch("hubspot_sync.task_helpers.is_uai_order", return_value=False) + + course_run = CourseRunFactory.create() + + with reversion.create_revision(): + product = ProductFactory.create(purchasable_object=course_run) + + order = OrderFactory.create() + LineFactory.create( + order=order, + product_version=Version.objects.get_for_object(product).first(), + purchased_object=course_run, + ) + + sync_hubspot_deal(order) + + mock_sync.assert_called_once() + + +def test_sync_hubspot_deal_proceeds_when_not_enrolled_in_program( + mocker, mock_exception_log, settings +): + """sync_hubspot_deal should proceed if the user is not enrolled in the program containing this course.""" + settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN = "faketoken" # noqa: S105 + + mock_sync = mocker.patch( + "hubspot_sync.task_helpers.tasks.sync_deal_with_hubspot_targeted.apply_async" + ) + mocker.patch("hubspot_sync.task_helpers.is_uai_order", return_value=False) + + course_run = CourseRunFactory.create() + program = ProgramFactory.create() + program.requirements_root.add_child( + node_type=ProgramRequirementNodeType.COURSE, + course=course_run.course, + ) + + with reversion.create_revision(): + product = ProductFactory.create(purchasable_object=course_run) + + order = OrderFactory.create() + LineFactory.create( + order=order, + product_version=Version.objects.get_for_object(product).first(), + purchased_object=course_run, + ) + # Intentionally do not enroll the user in the program + + sync_hubspot_deal(order) + + mock_sync.assert_called_once() + + @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""" From a7e115e1ce75428a4f8297635217396bcf5bb450 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:09:06 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- hubspot_sync/task_helpers.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/hubspot_sync/task_helpers.py b/hubspot_sync/task_helpers.py index 5915bcfe0c..9153d0f99a 100644 --- a/hubspot_sync/task_helpers.py +++ b/hubspot_sync/task_helpers.py @@ -48,10 +48,13 @@ def _order_is_for_program_enrolled_course(order: Order) -> bool: purchased_object = line.purchased_object if not purchased_object: continue - if isinstance(purchased_object, CourseRun) and ProgramEnrollment.objects.filter( - user=order.purchaser, - program__all_requirements__course=purchased_object.course, - ).exists(): + if ( + isinstance(purchased_object, CourseRun) + and ProgramEnrollment.objects.filter( + user=order.purchaser, + program__all_requirements__course=purchased_object.course, + ).exists() + ): return True return False From 0d115ca3413c4e05084fa476b0c50925824dadca Mon Sep 17 00:00:00 2001 From: CP Date: Wed, 12 Aug 2026 13:46:12 -0400 Subject: [PATCH 3/3] Optimize enrolled program order check Reduce redundant queries when checking whether an order includes a course run from a program the purchaser is already enrolled in. The helper now gathers purchased course IDs first and performs a single `ProgramEnrollment` lookup instead of checking each order line individually. --- hubspot_sync/task_helpers.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/hubspot_sync/task_helpers.py b/hubspot_sync/task_helpers.py index 9153d0f99a..1b17a7dc13 100644 --- a/hubspot_sync/task_helpers.py +++ b/hubspot_sync/task_helpers.py @@ -44,19 +44,17 @@ def sync_hubspot_user(user: User): def _order_is_for_program_enrolled_course(order: Order) -> bool: """Return True if any line is a course run belonging to a program the purchaser is already enrolled in.""" - for line in order.lines.all(): - purchased_object = line.purchased_object - if not purchased_object: - continue - if ( - isinstance(purchased_object, CourseRun) - and ProgramEnrollment.objects.filter( - user=order.purchaser, - program__all_requirements__course=purchased_object.course, - ).exists() - ): - return True - return False + course_ids = [ + line.purchased_object.course_id + for line in order.lines.all() + if line.purchased_object and isinstance(line.purchased_object, CourseRun) + ] + if not course_ids: + return False + return ProgramEnrollment.objects.filter( + user=order.purchaser, + program__all_requirements__course__in=course_ids, + ).exists() def sync_hubspot_deal(order: Order):