diff --git a/courses/api_test.py b/courses/api_test.py index 02276c8955..f250255bbe 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -925,7 +925,9 @@ def test_sync_course_mode(settings, mocker, mocked_api_response, expect_success) [1.0, False, True, False, False, False], # noqa: PT007 ], ) +@patch("courses.signals.upsert_custom_properties") def test_course_run_certificate( # noqa: PLR0913 + mock_upsert_custom_properties, user, passed_grade_with_enrollment, grade, @@ -943,7 +945,7 @@ def test_course_run_certificate( # noqa: PLR0913 "hubspot_sync.task_helpers.sync_hubspot_user", ) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) passed_grade_with_enrollment.grade = grade passed_grade_with_enrollment.passed = passed @@ -963,7 +965,10 @@ def test_course_run_certificate( # noqa: PLR0913 assert deleted is exp_deleted -def test_course_run_certificate_idempotent(passed_grade_with_enrollment, mocker, user): +@patch("courses.signals.upsert_custom_properties") +def test_course_run_certificate_idempotent( + mock_upsert_custom_properties, passed_grade_with_enrollment, mocker, user +): """ Test that the certificate generation is idempotent """ @@ -971,7 +976,7 @@ def test_course_run_certificate_idempotent(passed_grade_with_enrollment, mocker, "hubspot_sync.task_helpers.sync_hubspot_user", ) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) # Certificate is created the first time certificate, created, deleted = process_course_run_grade_certificate( @@ -992,12 +997,15 @@ def test_course_run_certificate_idempotent(passed_grade_with_enrollment, mocker, assert not deleted -def test_course_run_certificate_not_passing(passed_grade_with_enrollment, mocker): +@patch("courses.signals.upsert_custom_properties") +def test_course_run_certificate_not_passing( + mock_upsert_custom_properties, passed_grade_with_enrollment, mocker +): """ Test that the certificate is not generated if the grade is set to not passed """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) # Initially the certificate is created certificate, created, deleted = process_course_run_grade_certificate( @@ -1039,8 +1047,12 @@ def test_generate_course_certificates_no_valid_course_run(settings, courses_api_ ) +@patch("courses.signals.upsert_custom_properties") def test_generate_course_certificates_self_paced_course( - mocker, courses_api_logs, passed_grade_with_enrollment + mock_upsert_custom_properties, + mocker, + courses_api_logs, + passed_grade_with_enrollment, ): """Test that certificates are generated for self paced course runs independent of course run end date""" course_run = passed_grade_with_enrollment.course_run @@ -1048,7 +1060,7 @@ def test_generate_course_certificates_self_paced_course( course_run.is_self_paced = True course_run.save() mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) mocker.patch( "courses.api.ensure_course_run_grade", @@ -1073,7 +1085,9 @@ def test_generate_course_certificates_self_paced_course( (False, None), ], ) +@patch("courses.signals.upsert_custom_properties") def test_course_certificates_with_course_end_date_self_paced_combination( # noqa: PLR0913 + mock_upsert_custom_properties, mocker, settings, courses_api_logs, @@ -1093,7 +1107,7 @@ def test_course_certificates_with_course_end_date_self_paced_combination( # noq "hubspot_sync.task_helpers.sync_hubspot_user", ) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) mocker.patch( "courses.api.exception_logging_generator", @@ -1112,8 +1126,13 @@ def test_course_certificates_with_course_end_date_self_paced_combination( # noq ) +@patch("courses.signals.upsert_custom_properties") def test_generate_course_certificates_with_course_end_date( - mocker, courses_api_logs, passed_grade_with_enrollment, settings + mock_upsert_custom_properties, + mocker, + courses_api_logs, + passed_grade_with_enrollment, + settings, ): """Test that certificates are generated for passed grades when there are valid course runs for certificates""" course_run = passed_grade_with_enrollment.course_run @@ -1122,7 +1141,7 @@ def test_generate_course_certificates_with_course_end_date( user = passed_grade_with_enrollment.user mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) mocker.patch( "courses.api.ensure_course_run_grade", @@ -1139,10 +1158,11 @@ def test_generate_course_certificates_with_course_end_date( ) -def test_course_run_certificates_access(mocker): +@patch("courses.signals.upsert_custom_properties") +def test_course_run_certificates_access(mock_upsert_custom_properties, mocker): """Tests that the revoke and unrevoke for a course run certificates sets the states properly""" mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) test_certificate = CourseRunCertificateFactory.create(is_revoked=False) @@ -1264,7 +1284,9 @@ def test_generate_program_certificate_failure_missing_certificates( assert len(ProgramCertificate.objects.all()) == 0 +@patch("courses.signals.upsert_custom_properties") def test_generate_program_certificate_failure_not_all_passed( + mock_upsert_custom_properties, user, program_with_requirements, # noqa: F811 mocker, @@ -1274,7 +1296,7 @@ def test_generate_program_certificate_failure_not_all_passed( if there is not any course_run certificate for the given course. """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) courses = CourseFactory.create_batch(3) course_runs = CourseRunFactory.create_batch(3, course=factory.Iterator(courses)) @@ -1291,7 +1313,10 @@ def test_generate_program_certificate_failure_not_all_passed( assert len(ProgramCertificate.objects.all()) == 0 -def test_generate_program_certificate_success_single_requirement_course(user, mocker): +@patch("courses.signals.upsert_custom_properties") +def test_generate_program_certificate_success_single_requirement_course( + mock_upsert_custom_properties, user, mocker +): """ Test that generate_program_certificate generates a program certificate for a Program with a single required Course. """ @@ -1299,7 +1324,7 @@ def test_generate_program_certificate_success_single_requirement_course(user, mo "hubspot_sync.task_helpers.sync_hubspot_user", ) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) course = CourseFactory.create() program = ProgramFactory.create() @@ -1323,7 +1348,10 @@ def test_generate_program_certificate_success_single_requirement_course(user, mo patched_sync_hubspot_user.assert_called_once_with(user) -def test_generate_program_certificate_success_multiple_required_courses(user, mocker): +@patch("courses.signals.upsert_custom_properties") +def test_generate_program_certificate_success_multiple_required_courses( + mock_upsert_custom_properties, user, mocker +): """ Test that generate_program_certificate generate a program certificate """ @@ -1331,7 +1359,7 @@ def test_generate_program_certificate_success_multiple_required_courses(user, mo "hubspot_sync.task_helpers.sync_hubspot_user", ) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) courses = CourseFactory.create_batch(3) program = ProgramFactory.create() @@ -1356,13 +1384,16 @@ def test_generate_program_certificate_success_multiple_required_courses(user, mo patched_sync_hubspot_user.assert_called_once_with(user) -def test_generate_program_certificate_success_minimum_electives_not_met(user, mocker): +@patch("courses.signals.upsert_custom_properties") +def test_generate_program_certificate_success_minimum_electives_not_met( + mock_upsert_custom_properties, user, mocker +): """ Test that generate_program_certificate does not generate a program certificate if minimum electives have not been met. """ courses = CourseFactory.create_batch(3) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) # Create Program with 2 minimum elective courses. @@ -1404,7 +1435,9 @@ def test_generate_program_certificate_success_minimum_electives_not_met(user, mo assert len(ProgramCertificate.objects.all()) == 0 +@patch("courses.signals.upsert_custom_properties") def test_force_generate_program_certificate_success( + mock_upsert_custom_properties, user, program_with_requirements, # noqa: F811 mocker, @@ -1417,7 +1450,7 @@ def test_force_generate_program_certificate_success( "hubspot_sync.task_helpers.sync_hubspot_user", ) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) courses = CourseFactory.create_batch(3) course_runs = CourseRunFactory.create_batch(3, course=factory.Iterator(courses)) @@ -1480,7 +1513,9 @@ def test_program_certificates_access(): assert test_certificate.is_revoked is False +@patch("courses.signals.upsert_custom_properties") def test_generate_program_certificate_failure_not_all_passed_nested_elective_stipulation( + mock_upsert_custom_properties, user, mocker, ): @@ -1491,7 +1526,7 @@ def test_generate_program_certificate_failure_not_all_passed_nested_elective_sti courses = CourseFactory.create_batch(3) course_runs = CourseRunFactory.create_batch(3, course=factory.Iterator(courses)) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) # Create Program program = ProgramFactory.create() @@ -1566,7 +1601,10 @@ def test_program_enrollment_unenrollment_re_enrollment( ).exists() -def test_generate_program_certificate_with_subprogram_requirement(user, mocker): +@patch("courses.signals.upsert_custom_properties") +def test_generate_program_certificate_with_subprogram_requirement( + mock_upsert_custom_properties, user, mocker +): """ Test that generate_program_certificate considers sub-program (nested program) requirements when determining if a user has earned a program certificate. @@ -1575,7 +1613,7 @@ def test_generate_program_certificate_with_subprogram_requirement(user, mocker): "hubspot_sync.task_helpers.sync_hubspot_user", ) mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) # Create a sub-program that the user will complete @@ -1620,7 +1658,7 @@ def test_generate_program_certificate_with_subprogram_requirement_missing_certif sub-program certificate is missing. """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) # Create a sub-program @@ -1643,13 +1681,16 @@ def test_generate_program_certificate_with_subprogram_requirement_missing_certif assert len(ProgramCertificate.objects.all()) == 0 -def test_generate_program_certificate_with_revoked_subprogram_certificate(user, mocker): +@patch("courses.signals.upsert_custom_properties") +def test_generate_program_certificate_with_revoked_subprogram_certificate( + mock_upsert_custom_properties, user, mocker +): """ Test that generate_program_certificate does NOT consider revoked sub-program certificates when determining if a user has earned a program certificate. """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) # Create a sub-program diff --git a/courses/management/commands/test_manage_certificate.py b/courses/management/commands/test_manage_certificate.py index be57fa1d27..8e7b8e4911 100644 --- a/courses/management/commands/test_manage_certificate.py +++ b/courses/management/commands/test_manage_certificate.py @@ -125,7 +125,7 @@ def test_certificate_management_revoke_unrevoke_invalid_args( def test_certificate_management_revoke_unrevoke_success(user, revoke, unrevoke, mocker): """Test that certificate revoke, un-revoke work as expected and manage the certificate access properly""" mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) course_run = CourseRunFactory.create() certificate = CourseRunCertificateFactory( @@ -150,7 +150,7 @@ def test_certificate_management_create(mocker, user, edx_grade_json, revoked): when a user is provided """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) edx_grade = CurrentGrade(edx_grade_json) course_run = CourseRunFactory.create() @@ -192,7 +192,7 @@ def test_certificate_management_create_no_user(mocker, edx_grade_json, user): enrolled users in a run when no user is provided """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) passed_edx_grade = CurrentGrade(edx_grade_json) course_run = CourseRunFactory.create() diff --git a/courses/management/commands/test_manage_program_certificate.py b/courses/management/commands/test_manage_program_certificate.py index 1d47420252..ae02cabd10 100644 --- a/courses/management/commands/test_manage_program_certificate.py +++ b/courses/management/commands/test_manage_program_certificate.py @@ -130,7 +130,7 @@ def test_program_certificate_management_create( creates the program certificate for a user """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) courses = CourseFactory.create_batch(2) program_with_empty_requirements.add_requirement(courses[0]) @@ -162,7 +162,7 @@ def test_program_certificate_management_force_create( forcefully creates the certificate for a user """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) courses = CourseFactory.create_batch(3) course_runs = CourseRunFactory.create_batch(3, course=factory.Iterator(courses)) diff --git a/courses/models_test.py b/courses/models_test.py index bfce94a114..89c0321383 100644 --- a/courses/models_test.py +++ b/courses/models_test.py @@ -420,7 +420,7 @@ def test_course_run_certificate_start_end_dates_and_page_revision(mocker): Test that the CourseRunCertificate start_end_dates property works properly """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) certificate = CourseRunCertificateFactory.create( course_run__course__page__certificate_page__product_name="product_name" @@ -441,7 +441,7 @@ def test_program_certificate_start_end_dates_and_page_revision(user, mocker): The end date is the date the user received the program certificate. """ mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) now = now_in_utc() start_date = now + timedelta(days=1) diff --git a/courses/signals.py b/courses/signals.py index 0ed43b0913..6e9e07581f 100644 --- a/courses/signals.py +++ b/courses/signals.py @@ -2,6 +2,8 @@ Signals for mitxonline course certificates """ +import logging + from django.db import transaction from django.db.models.signals import post_save from django.dispatch import receiver @@ -11,6 +13,7 @@ CourseRunCertificate, Program, ) +from hubspot_sync.api import upsert_custom_properties from hubspot_sync.task_helpers import sync_hubspot_user @@ -40,4 +43,11 @@ def handle_create_course_run_certificate( transaction.on_commit( lambda: generate_multiple_programs_certificate(user, programs) ) - sync_hubspot_user(instance.user) + + try: + upsert_custom_properties() + sync_hubspot_user(instance.user) + except Exception: # pylint: disable=broad-except + logger = logging.getLogger(__name__) + logger.exception("Error syncing Hubspot user") + # avoid blocking certificate creation diff --git a/hubspot_sync/api.py b/hubspot_sync/api.py index 707decd7a8..62fa78aee7 100644 --- a/hubspot_sync/api.py +++ b/hubspot_sync/api.py @@ -2,6 +2,7 @@ import logging import re +import sys from decimal import Decimal from typing import List # noqa: UP035 @@ -24,17 +25,712 @@ get_all_objects, get_line_items_for_deal, make_object_properties_message, + sync_object_property, + sync_property_group, transform_object_properties, upsert_object_request, ) from mitol.hubspot_api.models import HubspotObject +from courses.constants import ALL_ENROLL_CHANGE_STATUSES +from courses.models import CourseRun, Program +from ecommerce import models +from ecommerce.constants import ( + DISCOUNT_TYPE_DOLLARS_OFF, + DISCOUNT_TYPE_FIXED_PRICE, + DISCOUNT_TYPE_PERCENT_OFF, +) from ecommerce.models import Line, Order, Product from hubspot_sync.rate_limiter import wait_for_hubspot_rate_limit -from users.models import User +from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE +from users.models import ( + COMPANY_SIZE_CHOICES, + GENDER_CHOICES, + HIGHEST_EDUCATION_CHOICES, + YRS_EXPERIENCE_CHOICES, + User, +) log = logging.getLogger(__name__) +CUSTOM_ECOMMERCE_PROPERTIES = { + # defines 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 + "deals": { + "groups": [{"name": "coupon", "label": "Coupon"}], + "properties": [ + { + "name": "status", + "label": "Order Status", + "description": "The current status of the order", + "groupName": "dealinformation", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": models.OrderStatus.FULFILLED, + "label": models.OrderStatus.FULFILLED, + "displayOrder": 0, + "hidden": False, + }, + { + "value": models.OrderStatus.CANCELED, + "label": models.OrderStatus.CANCELED, + "displayOrder": 1, + "hidden": False, + }, + { + "value": models.OrderStatus.ERRORED, + "label": models.OrderStatus.ERRORED, + "displayOrder": 2, + "hidden": False, + }, + { + "value": models.OrderStatus.DECLINED, + "label": models.OrderStatus.DECLINED, + "displayOrder": 3, + "hidden": False, + }, + { + "value": models.OrderStatus.PENDING, + "label": models.OrderStatus.PENDING, + "displayOrder": 4, + "hidden": False, + }, + { + "value": models.OrderStatus.REFUNDED, + "label": models.OrderStatus.REFUNDED, + "displayOrder": 5, + "hidden": False, + }, + { + "value": models.OrderStatus.PARTIALLY_REFUNDED, + "label": models.OrderStatus.PARTIALLY_REFUNDED, + "displayOrder": 6, + "hidden": False, + }, + { + "value": models.OrderStatus.REVIEW, + "label": models.OrderStatus.REVIEW, + "displayOrder": 7, + "hidden": False, + }, + ], + }, + { + "name": "discount_percent", + "label": "Percent Discount", + "description": "Percentage off regular price", + "groupName": "coupon", + "type": "number", + "fieldType": "number", + }, + { + "name": "discount_amount", + "label": "Discount savings", + "description": "The discount on the deal as an amount.", + "groupName": "coupon", + "type": "number", + "fieldType": "number", + }, + { + "name": "discount_type", + "label": "Discount Type", + "description": "Type of discount (percent-off or dollars-off or fixed-price)", + "groupName": "coupon", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": DISCOUNT_TYPE_PERCENT_OFF, + "label": DISCOUNT_TYPE_PERCENT_OFF, + "displayOrder": 0, + "hidden": False, + }, + { + "value": DISCOUNT_TYPE_DOLLARS_OFF, + "label": DISCOUNT_TYPE_DOLLARS_OFF, + "displayOrder": 1, + "hidden": False, + }, + { + "value": DISCOUNT_TYPE_FIXED_PRICE, + "label": DISCOUNT_TYPE_FIXED_PRICE, + "displayOrder": 2, + "hidden": False, + }, + ], + }, + { + "name": "coupon_code", + "label": "Coupon Code", + "description": "The coupon code used for the purchase", + "groupName": "coupon", + "type": "string", + "fieldType": "text", + }, + { + "name": "unique_app_id", + "label": "Unique App ID", + "description": "The unique app ID for the deal", + "groupName": "dealinformation", + "type": "string", + "fieldType": "text", + "hasUniqueValue": True, + "hidden": True, + }, + ], + }, + "contacts": { + "groups": [], + "properties": [ + { + "name": "name", + "label": "Name", + "description": "Full name", + "groupName": "contactinformation", + "type": "string", + "fieldType": "text", + }, + { + "name": "yearofbirth", + "label": "Year of birth", + "description": "Year of birth", + "groupName": "contactinformation", + "type": "number", + "fieldType": "number", + }, + { + "name": "gender", + "label": "Gender", + "description": "Gender", + "groupName": "contactinformation", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": GENDER_CHOICES[0][0], + "label": GENDER_CHOICES[0][1], + "displayOrder": 0, + "hidden": False, + }, + { + "value": GENDER_CHOICES[1][0], + "label": GENDER_CHOICES[1][1], + "displayOrder": 1, + "hidden": False, + }, + { + "value": GENDER_CHOICES[2][0], + "label": GENDER_CHOICES[2][1], + "displayOrder": 2, + "hidden": False, + }, + { + "value": GENDER_CHOICES[3][0], + "label": GENDER_CHOICES[3][1], + "displayOrder": 3, + "hidden": False, + }, + { + "value": GENDER_CHOICES[4][0], + "label": GENDER_CHOICES[4][1], + "displayOrder": 4, + "hidden": False, + }, + ], + }, + { + "name": "companysize", + "label": "Number of employees", + "description": "Company size", + "groupName": "contactinformation", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": COMPANY_SIZE_CHOICES[1][0], + "label": COMPANY_SIZE_CHOICES[1][1], + "displayOrder": 0, + "hidden": False, + }, + { + "value": COMPANY_SIZE_CHOICES[2][0], + "label": COMPANY_SIZE_CHOICES[2][1], + "displayOrder": 1, + "hidden": False, + }, + { + "value": COMPANY_SIZE_CHOICES[3][0], + "label": COMPANY_SIZE_CHOICES[3][1], + "displayOrder": 2, + "hidden": False, + }, + { + "value": COMPANY_SIZE_CHOICES[4][0], + "label": COMPANY_SIZE_CHOICES[4][1], + "displayOrder": 3, + "hidden": False, + }, + { + "value": COMPANY_SIZE_CHOICES[5][0], + "label": COMPANY_SIZE_CHOICES[5][1], + "displayOrder": 4, + "hidden": False, + }, + { + "value": COMPANY_SIZE_CHOICES[6][0], + "label": COMPANY_SIZE_CHOICES[6][1], + "displayOrder": 5, + "hidden": False, + }, + { + "value": COMPANY_SIZE_CHOICES[7][0], + "label": COMPANY_SIZE_CHOICES[7][1], + "displayOrder": 6, + "hidden": False, + }, + ], + }, + { + "name": "jobfunction", + "label": "Job Function", + "description": "Job Function", + "groupName": "contactinformation", + "type": "string", + "fieldType": "text", + }, + { + "name": "yearsexperience", + "label": "Years of experience", + "description": "Years of experience", + "groupName": "contactinformation", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": YRS_EXPERIENCE_CHOICES[1][0], + "label": YRS_EXPERIENCE_CHOICES[1][1], + "displayOrder": 0, + "hidden": False, + }, + { + "value": YRS_EXPERIENCE_CHOICES[2][0], + "label": YRS_EXPERIENCE_CHOICES[2][1], + "displayOrder": 1, + "hidden": False, + }, + { + "value": YRS_EXPERIENCE_CHOICES[3][0], + "label": YRS_EXPERIENCE_CHOICES[3][1], + "displayOrder": 2, + "hidden": False, + }, + { + "value": YRS_EXPERIENCE_CHOICES[4][0], + "label": YRS_EXPERIENCE_CHOICES[4][1], + "displayOrder": 3, + "hidden": False, + }, + { + "value": YRS_EXPERIENCE_CHOICES[5][0], + "label": YRS_EXPERIENCE_CHOICES[5][1], + "displayOrder": 4, + "hidden": False, + }, + { + "value": YRS_EXPERIENCE_CHOICES[6][0], + "label": YRS_EXPERIENCE_CHOICES[6][1], + "displayOrder": 5, + "hidden": False, + }, + { + "value": YRS_EXPERIENCE_CHOICES[7][0], + "label": YRS_EXPERIENCE_CHOICES[7][1], + "displayOrder": 6, + "hidden": False, + }, + ], + }, + { + "name": "leadershiplevel", + "label": "Leadership level", + "description": "Leadership level", + "groupName": "contactinformation", + "type": "string", + "fieldType": "text", + }, + { + "name": "highesteducation", + "label": "Highest education", + "description": "Highest level of education completed", + "groupName": "contactinformation", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": HIGHEST_EDUCATION_CHOICES[1][0], + "label": HIGHEST_EDUCATION_CHOICES[1][1], + "displayOrder": 0, + "hidden": False, + }, + { + "value": HIGHEST_EDUCATION_CHOICES[2][0], + "label": HIGHEST_EDUCATION_CHOICES[2][1], + "displayOrder": 1, + "hidden": False, + }, + { + "value": HIGHEST_EDUCATION_CHOICES[3][0], + "label": HIGHEST_EDUCATION_CHOICES[3][1], + "displayOrder": 2, + "hidden": False, + }, + { + "value": HIGHEST_EDUCATION_CHOICES[4][0], + "label": HIGHEST_EDUCATION_CHOICES[4][1], + "displayOrder": 3, + "hidden": False, + }, + { + "value": HIGHEST_EDUCATION_CHOICES[5][0], + "label": HIGHEST_EDUCATION_CHOICES[5][1], + "displayOrder": 4, + "hidden": False, + }, + { + "value": HIGHEST_EDUCATION_CHOICES[6][0], + "label": HIGHEST_EDUCATION_CHOICES[6][1], + "displayOrder": 5, + "hidden": False, + }, + { + "value": HIGHEST_EDUCATION_CHOICES[7][0], + "label": HIGHEST_EDUCATION_CHOICES[7][1], + "displayOrder": 6, + "hidden": False, + }, + { + "value": HIGHEST_EDUCATION_CHOICES[8][0], + "label": HIGHEST_EDUCATION_CHOICES[8][1], + "displayOrder": 7, + "hidden": False, + }, + { + "value": HIGHEST_EDUCATION_CHOICES[9][0], + "label": HIGHEST_EDUCATION_CHOICES[9][1], + "displayOrder": 8, + "hidden": False, + }, + ], + }, + { + "name": "typeisstudent", + "label": "Is student", + "description": "Is a student", + "groupName": "contactinformation", + "type": "bool", + "fieldType": "booleancheckbox", + "options": [ + { + "value": True, + "label": "True", + "displayOrder": 0, + "hidden": False, + }, + { + "value": False, + "label": "False", + "displayOrder": 1, + "hidden": False, + }, + ], + }, + { + "name": "typeisprofessional", + "label": "Is professional", + "description": "Is a professional", + "groupName": "contactinformation", + "type": "bool", + "fieldType": "booleancheckbox", + "options": [ + { + "value": True, + "label": "True", + "displayOrder": 0, + "hidden": False, + }, + { + "value": False, + "label": "False", + "displayOrder": 1, + "hidden": False, + }, + ], + }, + { + "name": "typeiseducator", + "label": "Is educator", + "description": "Is a educator", + "groupName": "contactinformation", + "type": "bool", + "fieldType": "booleancheckbox", + "options": [ + { + "value": True, + "label": "True", + "displayOrder": 0, + "hidden": False, + }, + { + "value": False, + "label": "False", + "displayOrder": 1, + "hidden": False, + }, + ], + }, + { + "name": "typeisother", + "label": "Is other", + "description": "Is a other", + "groupName": "contactinformation", + "type": "bool", + "fieldType": "booleancheckbox", + "options": [ + { + "value": True, + "label": "True", + "displayOrder": 0, + "hidden": False, + }, + { + "value": False, + "label": "False", + "displayOrder": 1, + "hidden": False, + }, + ], + }, + ], + }, + "line_items": { + "groups": [], + "properties": [ + { + "name": "status", + "label": "Order Status", + "description": "The current status of the order associated with the line item", + "groupName": "lineiteminformation", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": models.OrderStatus.FULFILLED, + "label": models.OrderStatus.FULFILLED, + "displayOrder": 0, + "hidden": False, + }, + { + "value": models.OrderStatus.CANCELED, + "label": models.OrderStatus.CANCELED, + "displayOrder": 1, + "hidden": False, + }, + { + "value": models.OrderStatus.ERRORED, + "label": models.OrderStatus.ERRORED, + "displayOrder": 2, + "hidden": False, + }, + { + "value": models.OrderStatus.DECLINED, + "label": models.OrderStatus.DECLINED, + "displayOrder": 3, + "hidden": False, + }, + { + "value": models.OrderStatus.PENDING, + "label": models.OrderStatus.PENDING, + "displayOrder": 4, + "hidden": False, + }, + { + "value": models.OrderStatus.REFUNDED, + "label": models.OrderStatus.REFUNDED, + "displayOrder": 5, + "hidden": False, + }, + { + "value": models.OrderStatus.PARTIALLY_REFUNDED, + "label": models.OrderStatus.PARTIALLY_REFUNDED, + "displayOrder": 6, + "hidden": False, + }, + { + "value": models.OrderStatus.REVIEW, + "label": models.OrderStatus.REVIEW, + "displayOrder": 7, + "hidden": False, + }, + ], + }, + { + "name": "unique_app_id", + "label": "Unique App ID", + "description": "The unique app ID for the lineitem", + "groupName": "lineiteminformation", + "type": "string", + "fieldType": "text", + "hasUniqueValue": True, + "hidden": True, + }, + { + "name": "enrollment_mode", + "label": "Enrollment Mode", + "description": "The enrollment mode the user is currently enrolled into the product as.", + "groupName": "lineiteminformation", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": EDX_ENROLLMENT_AUDIT_MODE, + "label": EDX_ENROLLMENT_AUDIT_MODE, + "displayOrder": 0, + "hidden": False, + }, + { + "value": EDX_ENROLLMENT_VERIFIED_MODE, + "label": EDX_ENROLLMENT_VERIFIED_MODE, + "displayOrder": 1, + "hidden": False, + }, + ], + }, + { + "name": "change_status", + "label": "Change Status", + "description": "Any change in enrollment status.", + "groupName": "lineiteminformation", + "type": "enumeration", + "fieldType": "select", + "options": [ + { + "value": ALL_ENROLL_CHANGE_STATUSES[0], + "label": ALL_ENROLL_CHANGE_STATUSES[0], + "displayOrder": 0, + "hidden": False, + }, + { + "value": ALL_ENROLL_CHANGE_STATUSES[1], + "label": ALL_ENROLL_CHANGE_STATUSES[1], + "displayOrder": 1, + "hidden": False, + }, + { + "value": ALL_ENROLL_CHANGE_STATUSES[2], + "label": ALL_ENROLL_CHANGE_STATUSES[2], + "displayOrder": 2, + "hidden": False, + }, + { + "value": ALL_ENROLL_CHANGE_STATUSES[3], + "label": ALL_ENROLL_CHANGE_STATUSES[3], + "displayOrder": 3, + "hidden": False, + }, + ], + }, + ], + }, + "products": { + "groups": [], + "properties": [ + { + "name": "unique_app_id", + "label": "Unique App ID", + "description": "The unique app ID for the product", + "groupName": "productinformation", + "type": "string", + "fieldType": "text", + "hasUniqueValue": True, + "hidden": True, + }, + ], + }, +} + + +def _get_course_run_certificate_hubspot_property(): + """ + Creates a dictionary representation of a Hubspot checkbox, + populated with options using the string representation of all course runs. + + Returns: + dict: dictionary representing the properties for a HubSpot checkbox, + populated with the string representation of all course runs. + """ + course_runs = CourseRun.objects.all() + options_array = [ + { + "value": str(course_run), + "label": str(course_run), + "hidden": False, + } + for course_run in course_runs + ] + return { + "name": "course_run_certificates", + "label": "Course Run certificates", + "description": "Earned course run certificates.", + "groupName": "contactinformation", + "type": "enumeration", + "fieldType": "checkbox", + "options": options_array, + } + + +def _get_program_certificate_hubspot_property(): + """ + Creates a dictionary representation of a Hubspot checkbox, + populated with options using string representation of all programs. + + Returns: + dict: dictionary representing the properties for a HubSpot checkbox, + populated with the string representation of all programs. + """ + programs = Program.objects.all() + options_array = [ + { + "value": str(program), + "label": str(program), + "hidden": False, + } + for program in programs + ] + return { + "name": "program_certificates", + "label": "Program certificates", + "description": "Earned program certificates.", + "groupName": "contactinformation", + "type": "enumeration", + "fieldType": "checkbox", + "options": options_array, + } + + +def upsert_custom_properties(): + """Create or update all custom properties and groups""" + for ecommerce_object_type, ecommerce_object in CUSTOM_ECOMMERCE_PROPERTIES.items(): + for group in ecommerce_object["groups"]: + sys.stdout.write(f"Adding group {group}\n") + sync_property_group(ecommerce_object_type, group["name"], group["label"]) + for obj_property in ecommerce_object["properties"]: + sys.stdout.write(f"Adding property {obj_property}\n") + sync_object_property(ecommerce_object_type, obj_property) + sync_object_property("contacts", _get_course_run_certificate_hubspot_property()) + sync_object_property("contacts", _get_program_certificate_hubspot_property()) + def make_contact_create_message_list_from_user_ids( user_ids: List[int], # noqa: UP006 diff --git a/hubspot_sync/api_test.py b/hubspot_sync/api_test.py index 1cf9b0a30d..52a5dc81b0 100644 --- a/hubspot_sync/api_test.py +++ b/hubspot_sync/api_test.py @@ -37,7 +37,7 @@ def test_make_contact_sync_message(user, mocker): """Test make_contact_sync_message serializes a user and returns a properly formatted sync message""" mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", + "hubspot_sync.api.upsert_custom_properties", ) course_certificate_1 = CourseRunCertificateFactory.create(user=user) course_certificate_2 = CourseRunCertificateFactory.create(user=user) diff --git a/hubspot_sync/management/commands/configure_hubspot_properties.py b/hubspot_sync/management/commands/configure_hubspot_properties.py index 1ff2ac4f4b..eb865c1555 100644 --- a/hubspot_sync/management/commands/configure_hubspot_properties.py +++ b/hubspot_sync/management/commands/configure_hubspot_properties.py @@ -2,711 +2,15 @@ Management command to configure custom Hubspot properties for Contacts, Deals, Products, and Line Items """ -import sys - from django.core.management import BaseCommand from mitol.hubspot_api.api import ( delete_object_property, delete_property_group, object_property_exists, property_group_exists, - sync_object_property, - sync_property_group, -) - -from courses.constants import ALL_ENROLL_CHANGE_STATUSES -from courses.models import CourseRun, Program -from ecommerce import models -from ecommerce.constants import ( - DISCOUNT_TYPE_DOLLARS_OFF, - DISCOUNT_TYPE_FIXED_PRICE, - DISCOUNT_TYPE_PERCENT_OFF, ) -from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE -from users.models import ( - COMPANY_SIZE_CHOICES, - GENDER_CHOICES, - HIGHEST_EDUCATION_CHOICES, - YRS_EXPERIENCE_CHOICES, -) - -CUSTOM_ECOMMERCE_PROPERTIES = { - # defines 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 - "deals": { - "groups": [{"name": "coupon", "label": "Coupon"}], - "properties": [ - { - "name": "status", - "label": "Order Status", - "description": "The current status of the order", - "groupName": "dealinformation", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": models.OrderStatus.FULFILLED, - "label": models.OrderStatus.FULFILLED, - "displayOrder": 0, - "hidden": False, - }, - { - "value": models.OrderStatus.CANCELED, - "label": models.OrderStatus.CANCELED, - "displayOrder": 1, - "hidden": False, - }, - { - "value": models.OrderStatus.ERRORED, - "label": models.OrderStatus.ERRORED, - "displayOrder": 2, - "hidden": False, - }, - { - "value": models.OrderStatus.DECLINED, - "label": models.OrderStatus.DECLINED, - "displayOrder": 3, - "hidden": False, - }, - { - "value": models.OrderStatus.PENDING, - "label": models.OrderStatus.PENDING, - "displayOrder": 4, - "hidden": False, - }, - { - "value": models.OrderStatus.REFUNDED, - "label": models.OrderStatus.REFUNDED, - "displayOrder": 5, - "hidden": False, - }, - { - "value": models.OrderStatus.PARTIALLY_REFUNDED, - "label": models.OrderStatus.PARTIALLY_REFUNDED, - "displayOrder": 6, - "hidden": False, - }, - { - "value": models.OrderStatus.REVIEW, - "label": models.OrderStatus.REVIEW, - "displayOrder": 7, - "hidden": False, - }, - ], - }, - { - "name": "discount_percent", - "label": "Percent Discount", - "description": "Percentage off regular price", - "groupName": "coupon", - "type": "number", - "fieldType": "number", - }, - { - "name": "discount_amount", - "label": "Discount savings", - "description": "The discount on the deal as an amount.", - "groupName": "coupon", - "type": "number", - "fieldType": "number", - }, - { - "name": "discount_type", - "label": "Discount Type", - "description": "Type of discount (percent-off or dollars-off or fixed-price)", - "groupName": "coupon", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": DISCOUNT_TYPE_PERCENT_OFF, - "label": DISCOUNT_TYPE_PERCENT_OFF, - "displayOrder": 0, - "hidden": False, - }, - { - "value": DISCOUNT_TYPE_DOLLARS_OFF, - "label": DISCOUNT_TYPE_DOLLARS_OFF, - "displayOrder": 1, - "hidden": False, - }, - { - "value": DISCOUNT_TYPE_FIXED_PRICE, - "label": DISCOUNT_TYPE_FIXED_PRICE, - "displayOrder": 2, - "hidden": False, - }, - ], - }, - { - "name": "coupon_code", - "label": "Coupon Code", - "description": "The coupon code used for the purchase", - "groupName": "coupon", - "type": "string", - "fieldType": "text", - }, - { - "name": "unique_app_id", - "label": "Unique App ID", - "description": "The unique app ID for the deal", - "groupName": "dealinformation", - "type": "string", - "fieldType": "text", - "hasUniqueValue": True, - "hidden": True, - }, - ], - }, - "contacts": { - "groups": [], - "properties": [ - { - "name": "name", - "label": "Name", - "description": "Full name", - "groupName": "contactinformation", - "type": "string", - "fieldType": "text", - }, - { - "name": "yearofbirth", - "label": "Year of birth", - "description": "Year of birth", - "groupName": "contactinformation", - "type": "number", - "fieldType": "number", - }, - { - "name": "gender", - "label": "Gender", - "description": "Gender", - "groupName": "contactinformation", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": GENDER_CHOICES[0][0], - "label": GENDER_CHOICES[0][1], - "displayOrder": 0, - "hidden": False, - }, - { - "value": GENDER_CHOICES[1][0], - "label": GENDER_CHOICES[1][1], - "displayOrder": 1, - "hidden": False, - }, - { - "value": GENDER_CHOICES[2][0], - "label": GENDER_CHOICES[2][1], - "displayOrder": 2, - "hidden": False, - }, - { - "value": GENDER_CHOICES[3][0], - "label": GENDER_CHOICES[3][1], - "displayOrder": 3, - "hidden": False, - }, - { - "value": GENDER_CHOICES[4][0], - "label": GENDER_CHOICES[4][1], - "displayOrder": 4, - "hidden": False, - }, - ], - }, - { - "name": "companysize", - "label": "Number of employees", - "description": "Company size", - "groupName": "contactinformation", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": COMPANY_SIZE_CHOICES[1][0], - "label": COMPANY_SIZE_CHOICES[1][1], - "displayOrder": 0, - "hidden": False, - }, - { - "value": COMPANY_SIZE_CHOICES[2][0], - "label": COMPANY_SIZE_CHOICES[2][1], - "displayOrder": 1, - "hidden": False, - }, - { - "value": COMPANY_SIZE_CHOICES[3][0], - "label": COMPANY_SIZE_CHOICES[3][1], - "displayOrder": 2, - "hidden": False, - }, - { - "value": COMPANY_SIZE_CHOICES[4][0], - "label": COMPANY_SIZE_CHOICES[4][1], - "displayOrder": 3, - "hidden": False, - }, - { - "value": COMPANY_SIZE_CHOICES[5][0], - "label": COMPANY_SIZE_CHOICES[5][1], - "displayOrder": 4, - "hidden": False, - }, - { - "value": COMPANY_SIZE_CHOICES[6][0], - "label": COMPANY_SIZE_CHOICES[6][1], - "displayOrder": 5, - "hidden": False, - }, - { - "value": COMPANY_SIZE_CHOICES[7][0], - "label": COMPANY_SIZE_CHOICES[7][1], - "displayOrder": 6, - "hidden": False, - }, - ], - }, - { - "name": "jobfunction", - "label": "Job Function", - "description": "Job Function", - "groupName": "contactinformation", - "type": "string", - "fieldType": "text", - }, - { - "name": "yearsexperience", - "label": "Years of experience", - "description": "Years of experience", - "groupName": "contactinformation", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": YRS_EXPERIENCE_CHOICES[1][0], - "label": YRS_EXPERIENCE_CHOICES[1][1], - "displayOrder": 0, - "hidden": False, - }, - { - "value": YRS_EXPERIENCE_CHOICES[2][0], - "label": YRS_EXPERIENCE_CHOICES[2][1], - "displayOrder": 1, - "hidden": False, - }, - { - "value": YRS_EXPERIENCE_CHOICES[3][0], - "label": YRS_EXPERIENCE_CHOICES[3][1], - "displayOrder": 2, - "hidden": False, - }, - { - "value": YRS_EXPERIENCE_CHOICES[4][0], - "label": YRS_EXPERIENCE_CHOICES[4][1], - "displayOrder": 3, - "hidden": False, - }, - { - "value": YRS_EXPERIENCE_CHOICES[5][0], - "label": YRS_EXPERIENCE_CHOICES[5][1], - "displayOrder": 4, - "hidden": False, - }, - { - "value": YRS_EXPERIENCE_CHOICES[6][0], - "label": YRS_EXPERIENCE_CHOICES[6][1], - "displayOrder": 5, - "hidden": False, - }, - { - "value": YRS_EXPERIENCE_CHOICES[7][0], - "label": YRS_EXPERIENCE_CHOICES[7][1], - "displayOrder": 6, - "hidden": False, - }, - ], - }, - { - "name": "leadershiplevel", - "label": "Leadership level", - "description": "Leadership level", - "groupName": "contactinformation", - "type": "string", - "fieldType": "text", - }, - { - "name": "highesteducation", - "label": "Highest education", - "description": "Highest level of education completed", - "groupName": "contactinformation", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": HIGHEST_EDUCATION_CHOICES[1][0], - "label": HIGHEST_EDUCATION_CHOICES[1][1], - "displayOrder": 0, - "hidden": False, - }, - { - "value": HIGHEST_EDUCATION_CHOICES[2][0], - "label": HIGHEST_EDUCATION_CHOICES[2][1], - "displayOrder": 1, - "hidden": False, - }, - { - "value": HIGHEST_EDUCATION_CHOICES[3][0], - "label": HIGHEST_EDUCATION_CHOICES[3][1], - "displayOrder": 2, - "hidden": False, - }, - { - "value": HIGHEST_EDUCATION_CHOICES[4][0], - "label": HIGHEST_EDUCATION_CHOICES[4][1], - "displayOrder": 3, - "hidden": False, - }, - { - "value": HIGHEST_EDUCATION_CHOICES[5][0], - "label": HIGHEST_EDUCATION_CHOICES[5][1], - "displayOrder": 4, - "hidden": False, - }, - { - "value": HIGHEST_EDUCATION_CHOICES[6][0], - "label": HIGHEST_EDUCATION_CHOICES[6][1], - "displayOrder": 5, - "hidden": False, - }, - { - "value": HIGHEST_EDUCATION_CHOICES[7][0], - "label": HIGHEST_EDUCATION_CHOICES[7][1], - "displayOrder": 6, - "hidden": False, - }, - { - "value": HIGHEST_EDUCATION_CHOICES[8][0], - "label": HIGHEST_EDUCATION_CHOICES[8][1], - "displayOrder": 7, - "hidden": False, - }, - { - "value": HIGHEST_EDUCATION_CHOICES[9][0], - "label": HIGHEST_EDUCATION_CHOICES[9][1], - "displayOrder": 8, - "hidden": False, - }, - ], - }, - { - "name": "typeisstudent", - "label": "Is student", - "description": "Is a student", - "groupName": "contactinformation", - "type": "bool", - "fieldType": "booleancheckbox", - "options": [ - { - "value": True, - "label": "True", - "displayOrder": 0, - "hidden": False, - }, - { - "value": False, - "label": "False", - "displayOrder": 1, - "hidden": False, - }, - ], - }, - { - "name": "typeisprofessional", - "label": "Is professional", - "description": "Is a professional", - "groupName": "contactinformation", - "type": "bool", - "fieldType": "booleancheckbox", - "options": [ - { - "value": True, - "label": "True", - "displayOrder": 0, - "hidden": False, - }, - { - "value": False, - "label": "False", - "displayOrder": 1, - "hidden": False, - }, - ], - }, - { - "name": "typeiseducator", - "label": "Is educator", - "description": "Is a educator", - "groupName": "contactinformation", - "type": "bool", - "fieldType": "booleancheckbox", - "options": [ - { - "value": True, - "label": "True", - "displayOrder": 0, - "hidden": False, - }, - { - "value": False, - "label": "False", - "displayOrder": 1, - "hidden": False, - }, - ], - }, - { - "name": "typeisother", - "label": "Is other", - "description": "Is a other", - "groupName": "contactinformation", - "type": "bool", - "fieldType": "booleancheckbox", - "options": [ - { - "value": True, - "label": "True", - "displayOrder": 0, - "hidden": False, - }, - { - "value": False, - "label": "False", - "displayOrder": 1, - "hidden": False, - }, - ], - }, - ], - }, - "line_items": { - "groups": [], - "properties": [ - { - "name": "status", - "label": "Order Status", - "description": "The current status of the order associated with the line item", - "groupName": "lineiteminformation", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": models.OrderStatus.FULFILLED, - "label": models.OrderStatus.FULFILLED, - "displayOrder": 0, - "hidden": False, - }, - { - "value": models.OrderStatus.CANCELED, - "label": models.OrderStatus.CANCELED, - "displayOrder": 1, - "hidden": False, - }, - { - "value": models.OrderStatus.ERRORED, - "label": models.OrderStatus.ERRORED, - "displayOrder": 2, - "hidden": False, - }, - { - "value": models.OrderStatus.DECLINED, - "label": models.OrderStatus.DECLINED, - "displayOrder": 3, - "hidden": False, - }, - { - "value": models.OrderStatus.PENDING, - "label": models.OrderStatus.PENDING, - "displayOrder": 4, - "hidden": False, - }, - { - "value": models.OrderStatus.REFUNDED, - "label": models.OrderStatus.REFUNDED, - "displayOrder": 5, - "hidden": False, - }, - { - "value": models.OrderStatus.PARTIALLY_REFUNDED, - "label": models.OrderStatus.PARTIALLY_REFUNDED, - "displayOrder": 6, - "hidden": False, - }, - { - "value": models.OrderStatus.REVIEW, - "label": models.OrderStatus.REVIEW, - "displayOrder": 7, - "hidden": False, - }, - ], - }, - { - "name": "unique_app_id", - "label": "Unique App ID", - "description": "The unique app ID for the lineitem", - "groupName": "lineiteminformation", - "type": "string", - "fieldType": "text", - "hasUniqueValue": True, - "hidden": True, - }, - { - "name": "enrollment_mode", - "label": "Enrollment Mode", - "description": "The enrollment mode the user is currently enrolled into the product as.", - "groupName": "lineiteminformation", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": EDX_ENROLLMENT_AUDIT_MODE, - "label": EDX_ENROLLMENT_AUDIT_MODE, - "displayOrder": 0, - "hidden": False, - }, - { - "value": EDX_ENROLLMENT_VERIFIED_MODE, - "label": EDX_ENROLLMENT_VERIFIED_MODE, - "displayOrder": 1, - "hidden": False, - }, - ], - }, - { - "name": "change_status", - "label": "Change Status", - "description": "Any change in enrollment status.", - "groupName": "lineiteminformation", - "type": "enumeration", - "fieldType": "select", - "options": [ - { - "value": ALL_ENROLL_CHANGE_STATUSES[0], - "label": ALL_ENROLL_CHANGE_STATUSES[0], - "displayOrder": 0, - "hidden": False, - }, - { - "value": ALL_ENROLL_CHANGE_STATUSES[1], - "label": ALL_ENROLL_CHANGE_STATUSES[1], - "displayOrder": 1, - "hidden": False, - }, - { - "value": ALL_ENROLL_CHANGE_STATUSES[2], - "label": ALL_ENROLL_CHANGE_STATUSES[2], - "displayOrder": 2, - "hidden": False, - }, - { - "value": ALL_ENROLL_CHANGE_STATUSES[3], - "label": ALL_ENROLL_CHANGE_STATUSES[3], - "displayOrder": 3, - "hidden": False, - }, - ], - }, - ], - }, - "products": { - "groups": [], - "properties": [ - { - "name": "unique_app_id", - "label": "Unique App ID", - "description": "The unique app ID for the product", - "groupName": "productinformation", - "type": "string", - "fieldType": "text", - "hasUniqueValue": True, - "hidden": True, - }, - ], - }, -} - - -def _get_course_run_certificate_hubspot_property(): - """ - Creates a dictionary representation of a Hubspot checkbox, - populated with options using the string representation of all course runs. - - Returns: - dict: dictionary representing the properties for a HubSpot checkbox, - populated with the string representation of all course runs. - """ - course_runs = CourseRun.objects.all() - options_array = [ - { - "value": str(course_run), - "label": str(course_run), - "hidden": False, - } - for course_run in course_runs - ] - return { - "name": "course_run_certificates", - "label": "Course Run certificates", - "description": "Earned course run certificates.", - "groupName": "contactinformation", - "type": "enumeration", - "fieldType": "checkbox", - "options": options_array, - } - -def _get_program_certificate_hubspot_property(): - """ - Creates a dictionary representation of a Hubspot checkbox, - populated with options using string representation of all programs. - - Returns: - dict: dictionary representing the properties for a HubSpot checkbox, - populated with the string representation of all programs. - """ - programs = Program.objects.all() - options_array = [ - { - "value": str(program), - "label": str(program), - "hidden": False, - } - for program in programs - ] - return { - "name": "program_certificates", - "label": "Program certificates", - "description": "Earned program certificates.", - "groupName": "contactinformation", - "type": "enumeration", - "fieldType": "checkbox", - "options": options_array, - } - - -def _upsert_custom_properties(): - """Create or update all custom properties and groups""" - for ecommerce_object_type, ecommerce_object in CUSTOM_ECOMMERCE_PROPERTIES.items(): - for group in ecommerce_object["groups"]: - sys.stdout.write(f"Adding group {group}\n") - sync_property_group(ecommerce_object_type, group["name"], group["label"]) - for obj_property in ecommerce_object["properties"]: - sys.stdout.write(f"Adding property {obj_property}\n") - sync_object_property(ecommerce_object_type, obj_property) - sync_object_property("contacts", _get_course_run_certificate_hubspot_property()) - sync_object_property("contacts", _get_program_certificate_hubspot_property()) +from hubspot_sync.api import CUSTOM_ECOMMERCE_PROPERTIES, upsert_custom_properties def _delete_custom_properties(): @@ -745,5 +49,5 @@ def handle(self, *args, **options): # noqa: ARG002 return else: print("Configuring custom groups and properties...") # noqa: T201 - _upsert_custom_properties() + upsert_custom_properties() print("Custom properties configured") # noqa: T201 diff --git a/hubspot_sync/serializers.py b/hubspot_sync/serializers.py index 3bc2aa7fe0..3cc6fe3c1a 100644 --- a/hubspot_sync/serializers.py +++ b/hubspot_sync/serializers.py @@ -289,7 +289,8 @@ def get_program_certificates(self, instance): user=instance, is_revoked=False ).select_related("program") program_name_array = [ - str(program_cert.program) for program_cert in programs_user_has_cert + str(program_cert.program).replace(";", "") + for program_cert in programs_user_has_cert ] return ";".join(program_name_array) @@ -299,7 +300,7 @@ def get_course_run_certificates(self, instance): user=instance, is_revoked=False ).select_related("course_run") course_run_name_array = [ - str(course_run_cert.course_run) + str(course_run_cert.course_run).replace(";", "") for course_run_cert in course_runs_user_has_cert ] return ";".join(course_run_name_array) diff --git a/hubspot_sync/serializers_test.py b/hubspot_sync/serializers_test.py index 8fc210e8fd..56dff18a63 100644 --- a/hubspot_sync/serializers_test.py +++ b/hubspot_sync/serializers_test.py @@ -5,6 +5,7 @@ # pylint: disable=unused-argument, redefined-outer-name from decimal import Decimal +from unittest.mock import patch import pytest from django.contrib.contenttypes.models import ContentType @@ -17,6 +18,7 @@ CourseRunEnrollmentFactory, CourseRunFactory, ProgramCertificateFactory, + ProgramFactory, ) from ecommerce.constants import ( DISCOUNT_TYPE_DOLLARS_OFF, @@ -191,11 +193,13 @@ def test_serialize_order_with_coupon( # noqa: PLR0913 } -def test_serialize_contact(settings, user, mocker): +@pytest.mark.django_db +@patch("courses.signals.upsert_custom_properties") +@patch("hubspot_sync.task_helpers.sync_hubspot_user") +def test_serialize_contact(mock_sync_user, mock_upsert, settings, user, mocker): """Test that HubspotContactSerializer includes program and course run certificates for the user""" - mocker.patch( - "hubspot_sync.management.commands.configure_hubspot_properties._upsert_custom_properties", - ) + mocker.patch("mitol.hubspot_api.api.sync_object_property") + mocker.patch("hubspot_sync.api.upsert_custom_properties") program_cert_1 = ProgramCertificateFactory.create(user=user) program_cert_2 = ProgramCertificateFactory.create(user=user) course_run_cert_1 = CourseRunCertificateFactory.create(user=user) @@ -209,3 +213,74 @@ def test_serialize_contact(settings, user, mocker): serialized_data["course_run_certificates"] == f"{course_run_cert_1.course_run!s};{course_run_cert_2.course_run!s}" ) + + +@pytest.mark.django_db +@patch("courses.signals.upsert_custom_properties") +@patch("hubspot_sync.task_helpers.sync_hubspot_user") +def test_serialize_contact_removes_semicolons_from_program_names( + mock_sync_user, mock_upsert, settings, user, mocker +): + """Test that HubspotContactSerializer removes semicolons from program certificate names""" + # Create a program certificate where the program's string representation contains a semicolon + program = ProgramFactory.create( + title="Test Program; With Semicolon", readable_id="test-program-with-semicolon" + ) + ProgramCertificateFactory.create(user=user, program=program) + + serialized_data = HubspotContactSerializer(instance=user).data + + # Ensure no semicolons remain in the final string + assert ";" not in serialized_data["program_certificates"] + + +@pytest.mark.django_db +@patch("courses.signals.upsert_custom_properties") +@patch("hubspot_sync.task_helpers.sync_hubspot_user") +def test_serialize_contact_removes_semicolons_from_course_run_names( + mock_sync_user, mock_upsert, settings, user, mocker +): + """Test that HubspotContactSerializer removes semicolons from course run certificate names""" + # Create a course run certificate where the course run's string representation contains a semicolon + course_run = CourseRunFactory.create( + title="Test Course; Run With Semicolon", + courseware_id="test-course-run-with-semicolon", + ) + CourseRunCertificateFactory.create(user=user, course_run=course_run) + + serialized_data = HubspotContactSerializer(instance=user).data + + assert ";" not in serialized_data["course_run_certificates"] + + +@pytest.mark.django_db +@patch("courses.signals.upsert_custom_properties") +@patch("hubspot_sync.task_helpers.sync_hubspot_user") +def test_serialize_contact_multiple_certificates_with_semicolons( + mock_sync_user, mock_upsert, settings, user, mocker +): + """Test that HubspotContactSerializer properly handles multiple certificates with semicolons""" + + program_1 = ProgramFactory.create(title="Program; One", readable_id="program-one") + program_2 = ProgramFactory.create(title="Program; Two", readable_id="program-two") + course_run_1 = CourseRunFactory.create( + title="Course; Run One", courseware_id="course-run-one" + ) + course_run_2 = CourseRunFactory.create( + title="Course; Run Two", courseware_id="course-run-two" + ) + # Create multiple certificates + ProgramCertificateFactory.create(user=user, program=program_1) + ProgramCertificateFactory.create(user=user, program=program_2) + CourseRunCertificateFactory.create(user=user, course_run=course_run_1) + CourseRunCertificateFactory.create(user=user, course_run=course_run_2) + + serialized_data = HubspotContactSerializer(instance=user).data + + # Count semicolons to ensure only separator semicolons remain + program_semicolons = serialized_data["program_certificates"].count(";") + course_run_semicolons = serialized_data["course_run_certificates"].count(";") + + # Should have exactly 1 separator semicolon (2 items = 1 separator) + assert program_semicolons == 1 + assert course_run_semicolons == 1