diff --git a/courses/signals.py b/courses/signals.py index 6e9e07581f..e11ba489cb 100644 --- a/courses/signals.py +++ b/courses/signals.py @@ -12,9 +12,9 @@ from courses.models import ( CourseRunCertificate, Program, + ProgramCertificate, ) -from hubspot_sync.api import upsert_custom_properties -from hubspot_sync.task_helpers import sync_hubspot_user +from hubspot_sync import tasks as hubspot_tasks @receiver( @@ -44,10 +44,37 @@ def handle_create_course_run_certificate( lambda: generate_multiple_programs_certificate(user, programs) ) - 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 + try: + transaction.on_commit( + lambda: hubspot_tasks.sync_course_run_certificate_with_hubspot.delay( + instance.id + ) + ) + except Exception: # pylint: disable=broad-except + logger = logging.getLogger(__name__) + logger.exception("Error syncing HubSpot course run certificate") + # avoid blocking certificate save flow + + +@receiver( + post_save, + sender=ProgramCertificate, + dispatch_uid="programcertificate_post_save", +) +def handle_create_program_certificate( + sender, # pylint: disable=unused-argument # noqa: ARG001 + instance, + created, + **kwargs, # pylint: disable=unused-argument # noqa: ARG001 +): + """When a ProgramCertificate model is created.""" + try: + transaction.on_commit( + lambda: hubspot_tasks.sync_program_certificate_with_hubspot.delay( + instance.id + ) + ) + except Exception: # pylint: disable=broad-except + logger = logging.getLogger(__name__) + logger.exception("Error syncing HubSpot program certificate") + # avoid blocking certificate save flow diff --git a/courses/signals_test.py b/courses/signals_test.py index 4f5ae43f54..4f3a7e6b89 100644 --- a/courses/signals_test.py +++ b/courses/signals_test.py @@ -10,6 +10,7 @@ CourseFactory, CourseRunCertificateFactory, CourseRunFactory, + ProgramCertificateFactory, ProgramFactory, UserFactory, ) @@ -17,6 +18,19 @@ pytestmark = pytest.mark.django_db +@pytest.fixture(autouse=True) +def mock_certificate_hubspot_sync_tasks(mocker): + """Mock certificate HubSpot sync tasks to avoid external API calls in signal tests.""" + return { + "course_run": mocker.patch( + "courses.signals.hubspot_tasks.sync_course_run_certificate_with_hubspot.delay" + ), + "program": mocker.patch( + "courses.signals.hubspot_tasks.sync_program_certificate_with_hubspot.delay" + ), + } + + # pylint: disable=unused-argument @patch("courses.signals.transaction.on_commit", side_effect=lambda callback: callback()) @patch("courses.signals.generate_multiple_programs_certificate", autospec=True) @@ -69,3 +83,37 @@ def test_generate_program_certificate_not_called( cert = CourseRunCertificateFactory.create(user=user, course_run=course_run) cert.save() generate_program_cert_mock.assert_not_called() + + +@patch("courses.signals.transaction.on_commit", side_effect=lambda callback: callback()) +def test_sync_course_certificate_with_hubspot_on_save( + mock_on_commit, mock_certificate_hubspot_sync_tasks +): + """Test that course certificate HubSpot sync is triggered on create and update.""" + sync_course_cert_mock = mock_certificate_hubspot_sync_tasks["course_run"] + cert = CourseRunCertificateFactory.create() + + sync_course_cert_mock.assert_called_once_with(cert.id) + + cert.issue_date = cert.issue_date + cert.save() + + assert sync_course_cert_mock.call_count == 2 + sync_course_cert_mock.assert_called_with(cert.id) + + +@patch("courses.signals.transaction.on_commit", side_effect=lambda callback: callback()) +def test_sync_program_certificate_with_hubspot_on_save( + mock_on_commit, mock_certificate_hubspot_sync_tasks +): + """Test that program certificate HubSpot sync is triggered on create and update.""" + sync_program_cert_mock = mock_certificate_hubspot_sync_tasks["program"] + cert = ProgramCertificateFactory.create() + + sync_program_cert_mock.assert_called_once_with(cert.id) + + cert.issue_date = cert.issue_date + cert.save() + + assert sync_program_cert_mock.call_count == 2 + sync_program_cert_mock.assert_called_with(cert.id) diff --git a/hubspot_sync/api.py b/hubspot_sync/api.py index f9a1fb3325..79cf35769c 100644 --- a/hubspot_sync/api.py +++ b/hubspot_sync/api.py @@ -40,7 +40,6 @@ from reversion.models import Version 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, @@ -64,6 +63,154 @@ # HubSpot internal option value for the "Checkout Abandoned" deal stage. CART_ADD_DEAL_STAGE = "checkout_abandoned" +# Schema definitions used by the create_hubspot_certificate_schema command. +CERTIFICATE_CUSTOM_OBJECT_SCHEMAS = { + "course_run_certificate": { + "labels": { + "singular": "Course Run Certificate", + "plural": "Course Run Certificates", + }, + "primaryDisplayProperty": "course_run_readable_id", + "searchableProperties": [ + "unique_app_id", + "course_run_readable_id", + "user_email", + ], + "associatedObjects": ["CONTACT"], + "properties": [ + { + "name": "unique_app_id", + "label": "Unique App ID", + "type": "string", + "fieldType": "text", + "groupName": "certificateinformation", + "hasUniqueValue": True, + }, + { + "name": "course_run_readable_id", + "label": "Course Run Readable ID", + "type": "string", + "fieldType": "text", + "groupName": "certificateinformation", + }, + { + "name": "course_run_title", + "label": "Course Run Title", + "type": "string", + "fieldType": "text", + "groupName": "certificateinformation", + }, + { + "name": "user_email", + "label": "User Email", + "type": "string", + "fieldType": "text", + "groupName": "certificateinformation", + }, + { + "name": "issue_date", + "label": "Issue Date", + "type": "datetime", + "fieldType": "date", + "groupName": "certificateinformation", + }, + { + "name": "is_revoked", + "label": "Is Revoked", + "type": "enumeration", + "fieldType": "booleancheckbox", + "groupName": "certificateinformation", + "options": [ + { + "label": "True", + "value": "true", + "hidden": False, + "displayOrder": 0, + }, + { + "label": "False", + "value": "false", + "hidden": False, + "displayOrder": 1, + }, + ], + }, + ], + }, + "program_certificate": { + "labels": { + "singular": "Program Certificate", + "plural": "Program Certificates", + }, + "primaryDisplayProperty": "program_readable_id", + "searchableProperties": [ + "unique_app_id", + "program_readable_id", + "user_email", + ], + "associatedObjects": ["CONTACT"], + "properties": [ + { + "name": "unique_app_id", + "label": "Unique App ID", + "type": "string", + "fieldType": "text", + "groupName": "certificateinformation", + "hasUniqueValue": True, + }, + { + "name": "program_readable_id", + "label": "Program Readable ID", + "type": "string", + "fieldType": "text", + "groupName": "certificateinformation", + }, + { + "name": "program_title", + "label": "Program Title", + "type": "string", + "fieldType": "text", + "groupName": "certificateinformation", + }, + { + "name": "user_email", + "label": "User Email", + "type": "string", + "fieldType": "text", + "groupName": "certificateinformation", + }, + { + "name": "issue_date", + "label": "Issue Date", + "type": "datetime", + "fieldType": "date", + "groupName": "certificateinformation", + }, + { + "name": "is_revoked", + "label": "Is Revoked", + "type": "enumeration", + "fieldType": "booleancheckbox", + "groupName": "certificateinformation", + "options": [ + { + "label": "True", + "value": "true", + "hidden": False, + "displayOrder": 0, + }, + { + "label": "False", + "value": "false", + "hidden": False, + "displayOrder": 1, + }, + ], + }, + ], + }, +} + 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 @@ -686,79 +833,292 @@ } -def _get_course_run_certificate_hubspot_property(): +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"]: + log.debug("Adding group %s", group["name"]) + sync_property_group(ecommerce_object_type, group["name"], group["label"]) + for obj_property in ecommerce_object["properties"]: + log.debug( + "Adding property %s for %s", + obj_property.get("name"), + ecommerce_object_type, + ) + sync_object_property(ecommerce_object_type, obj_property) + + +# --------------------------------------------------------------------------- +# Certificate custom object sync helpers +# --------------------------------------------------------------------------- + + +def _format_cert_unique_app_id(prefix: str, cert_id: int) -> str: + """Return a stable unique_app_id for a certificate record.""" + return format_app_id(f"{prefix}-{cert_id}") + + +def _find_hubspot_certificate_by_unique_app_id( + hubspot_client: HubspotApi, + object_type: str, + unique_app_id: str, +) -> str | None: + """Search HubSpot for an existing certificate custom object by unique_app_id. + + Returns the HubSpot object id string, or None if not found. + """ + wait_for_hubspot_rate_limit() + response = hubspot_client.crm.objects.search_api.do_search( + object_type=object_type, + 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 _associate_certificate_with_contact( + hubspot_client: HubspotApi, + cert_object_type: str, + cert_hubspot_id: str, + contact_hubspot_id: str, + association_type_id: int = None, # noqa: ARG001 +) -> None: + """Create a v4 association between a certificate custom object and a contact. + + Uses create_default() which auto-creates associations with the default type. + The association_type_id parameter is kept for backward compatibility but not used. """ - Creates a dictionary representation of a Hubspot checkbox, - populated with options using the string representation of all course runs. + wait_for_hubspot_rate_limit() + hubspot_client.crm.associations.v4.basic_api.create_default( + from_object_type=cert_object_type, + from_object_id=cert_hubspot_id, + to_object_type=HubspotObjectType.CONTACTS.value, + to_object_id=contact_hubspot_id, + ) + + +def _get_custom_object_type_id_by_name( + hubspot_client: HubspotApi, + object_type_name: str, +) -> str | None: + """Look up the objectTypeId (e.g., '2-62918642') from the custom object name. + + Returns the objectTypeId needed for API calls, or None if not found. + """ + try: + wait_for_hubspot_rate_limit() + schemas = hubspot_client.crm.schemas.core_api.get_all() + for schema in getattr(schemas, "results", []): + if schema.name == object_type_name: + return schema.object_type_id + except Exception: + log.exception( + "Failed to fetch objectTypeId for custom object name %s", + object_type_name, + ) + return None + + +def _get_cert_contact_association_type_id( + hubspot_client: HubspotApi, + cert_object_type: str, + setting_name: str, +) -> int | None: + """Return the association typeId for cert_object_type → contacts. + + Checks settings first, then queries HubSpot. Returns None if unavailable. + """ + setting_value = getattr(settings, setting_name, None) + if setting_value: + return int(setting_value) + try: + wait_for_hubspot_rate_limit() + types_response = hubspot_client.crm.associations.v4.definition_api.get_all( + from_object_type=cert_object_type, + to_object_type=HubspotObjectType.CONTACTS.value, + ) + if types_response.results: + return types_response.results[0].type_id + except Exception: + log.exception( + "Failed to fetch association type ID for custom object %s", + cert_object_type, + ) + return None + + +def sync_course_run_certificate_with_hubspot(cert) -> SimplePublicObject | None: + """Upsert a CourseRunCertificate as a HubSpot custom object and associate it to the owner contact. + + Requires HUBSPOT_COURSE_RUN_CERTIFICATE_OBJECT_TYPE to be set in settings + (run the create_hubspot_certificate_schema management command first). Returns: - dict: dictionary representing the properties for a HubSpot checkbox, - populated with the string representation of all course runs. + SimplePublicObject: The HubSpot object, or None if the object type is not configured. """ - course_runs = CourseRun.objects.all() - options_array = [ - { - "value": str(course_run).replace(";", ""), - "label": str(course_run).replace(";", ""), - "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, + object_type_name = getattr( + settings, "HUBSPOT_COURSE_RUN_CERTIFICATE_OBJECT_TYPE", None + ) + if not object_type_name or not settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN: + log.debug( + "Skipping course run certificate HubSpot sync: " + "HUBSPOT_COURSE_RUN_CERTIFICATE_OBJECT_TYPE is not configured." + ) + return None + + hubspot_client = HubspotApi(access_token=settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN) + + # Look up the objectTypeId from the custom object name + object_type_id = _get_custom_object_type_id_by_name( + hubspot_client, object_type_name + ) + if not object_type_id: + log.warning( + "Could not find objectTypeId for custom object %s; skipping sync", + object_type_name, + ) + return None + + unique_app_id = _format_cert_unique_app_id("crc", cert.id) + properties = { + "unique_app_id": unique_app_id, + "course_run_readable_id": cert.course_run.courseware_id, + "course_run_title": cert.course_run.title, + "user_email": cert.user.email, + "issue_date": str(int(cert.issue_date.timestamp() * 1000)), + "is_revoked": "true" if cert.is_revoked else "false", } + existing_id = _find_hubspot_certificate_by_unique_app_id( + hubspot_client, object_type_id, unique_app_id + ) -def _get_program_certificate_hubspot_property(): - """ - Creates a dictionary representation of a Hubspot checkbox, - populated with options using string representation of all programs. + wait_for_hubspot_rate_limit() + if existing_id: + result = hubspot_client.crm.objects.basic_api.update( + object_type=object_type_id, + object_id=existing_id, + simple_public_object_input=SimplePublicObjectInput(properties=properties), + ) + else: + result = hubspot_client.crm.objects.basic_api.create( + object_type=object_type_id, + simple_public_object_input_for_create=SimplePublicObjectInput( + properties=properties + ), + ) + + # Associate with the owning contact. + contact_id = _find_hubspot_contact_id_by_email(hubspot_client, cert.user.email) + if contact_id: + try: + _associate_certificate_with_contact( + hubspot_client, object_type_id, result.id, contact_id + ) + except Exception: + log.exception( + "Failed to associate course run certificate %s (hs_id=%s) " + "with contact %s", + cert.id, + result.id, + contact_id, + ) + + return result + + +def sync_program_certificate_with_hubspot(cert) -> SimplePublicObject | None: + """Upsert a ProgramCertificate as a HubSpot custom object and associate it to the owner contact. + + Requires HUBSPOT_PROGRAM_CERTIFICATE_OBJECT_TYPE to be set in settings + (run the create_hubspot_certificate_schema management command first). Returns: - dict: dictionary representing the properties for a HubSpot checkbox, - populated with the string representation of all programs. + SimplePublicObject: The HubSpot object, or None if the object type is not configured. """ - programs = Program.objects.all() - options_array = [ - { - "value": str(program).replace(";", ""), - "label": str(program).replace(";", ""), - "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, + object_type_name = getattr( + settings, "HUBSPOT_PROGRAM_CERTIFICATE_OBJECT_TYPE", None + ) + if not object_type_name or not settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN: + log.debug( + "Skipping program certificate HubSpot sync: " + "HUBSPOT_PROGRAM_CERTIFICATE_OBJECT_TYPE is not configured." + ) + return None + + hubspot_client = HubspotApi(access_token=settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN) + + # Look up the objectTypeId from the custom object name + object_type_id = _get_custom_object_type_id_by_name( + hubspot_client, object_type_name + ) + if not object_type_id: + log.warning( + "Could not find objectTypeId for custom object %s; skipping sync", + object_type_name, + ) + return None + + unique_app_id = _format_cert_unique_app_id("pgc", cert.id) + properties = { + "unique_app_id": unique_app_id, + "program_readable_id": cert.program.readable_id, + "program_title": cert.program.title, + "user_email": cert.user.email, + "issue_date": str(int(cert.issue_date.timestamp() * 1000)), + "is_revoked": "true" if cert.is_revoked else "false", } + existing_id = _find_hubspot_certificate_by_unique_app_id( + hubspot_client, object_type_id, unique_app_id + ) -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"]: - log.debug("Adding group %s", group["name"]) - sync_property_group(ecommerce_object_type, group["name"], group["label"]) - for obj_property in ecommerce_object["properties"]: - log.debug( - "Adding property %s for %s", - obj_property.get("name"), - ecommerce_object_type, + wait_for_hubspot_rate_limit() + if existing_id: + result = hubspot_client.crm.objects.basic_api.update( + object_type=object_type_id, + object_id=existing_id, + simple_public_object_input=SimplePublicObjectInput(properties=properties), + ) + else: + result = hubspot_client.crm.objects.basic_api.create( + object_type=object_type_id, + simple_public_object_input_for_create=SimplePublicObjectInput( + properties=properties + ), + ) + + # Associate with the owning contact. + contact_id = _find_hubspot_contact_id_by_email(hubspot_client, cert.user.email) + if contact_id: + try: + _associate_certificate_with_contact( + hubspot_client, object_type_id, result.id, contact_id + ) + except Exception: + log.exception( + "Failed to associate program certificate %s (hs_id=%s) with contact %s", + cert.id, + result.id, + contact_id, ) - 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()) + + return result def make_contact_create_message_list_from_user_ids( @@ -818,8 +1178,7 @@ def make_contact_sync_message_from_user( Args: user (User): User object. - skip_certificates (bool): If True, exclude certificate properties from the contact data. - Used for UAI HubSpot accounts that don't have certificate properties. + skip_certificates (bool): Deprecated no-op, retained for backward compatibility. Returns: SimplePublicObjectInput: Input object for upserting User data to Hubspot """ @@ -846,24 +1205,14 @@ def make_contact_sync_message_from_user( "type_is_other": "typeisother", } - # Only include certificate properties if not skipping them - if not skip_certificates: - contact_properties_map.update( - { - "program_certificates": "program_certificates", - "course_run_certificates": "course_run_certificates", - } - ) + # skip_certificates is kept for backward API compatibility but is now a no-op + # because certificates are synced as HubSpot custom objects, not contact properties. + _ = skip_certificates properties = HubspotContactSerializer(user).data properties.update(properties.pop("legal_address") or {}) properties.update(properties.pop("user_profile") or {}) - # Remove certificate data if skipping certificates - if skip_certificates: - properties.pop("program_certificates", None) - properties.pop("course_run_certificates", None) - hubspot_props = transform_object_properties(properties, contact_properties_map) return make_object_properties_message(hubspot_props) @@ -1812,14 +2161,9 @@ def _ensure_target_hubspot_custom_properties( } for object_type, config in CUSTOM_ECOMMERCE_PROPERTIES.items() } - # Skip certificate properties for UAI courses since they don't need them - if not skip_certificates: - object_configs[HubspotObjectType.CONTACTS.value]["properties"].extend( - [ - _get_course_run_certificate_hubspot_property(), - _get_program_certificate_hubspot_property(), - ] - ) + # skip_certificates is kept for backward compatibility; certificate sync + # now uses custom objects and no longer adds contact properties. + _ = skip_certificates for object_type, config in object_configs.items(): wait_for_hubspot_rate_limit() diff --git a/hubspot_sync/api_test.py b/hubspot_sync/api_test.py index 260cc34c53..96b3b713cb 100644 --- a/hubspot_sync/api_test.py +++ b/hubspot_sync/api_test.py @@ -43,10 +43,10 @@ def test_make_contact_sync_message(user, mocker): mocker.patch( "hubspot_sync.api.upsert_custom_properties", ) - course_certificate_1 = CourseRunCertificateFactory.create(user=user) - course_certificate_2 = CourseRunCertificateFactory.create(user=user) - program_certificate_1 = ProgramCertificateFactory.create(user=user) - program_certificate_2 = ProgramCertificateFactory.create(user=user) + CourseRunCertificateFactory.create(user=user) + CourseRunCertificateFactory.create(user=user) + ProgramCertificateFactory.create(user=user) + ProgramCertificateFactory.create(user=user) contact_sync_message = api.make_contact_sync_message_from_user(user) assert contact_sync_message.properties == { "country": user.legal_address.country, @@ -67,15 +67,97 @@ def test_make_contact_sync_message(user, mocker): "typeisprofessional": user.user_profile.type_is_professional, "typeiseducator": user.user_profile.type_is_educator, "typeisother": user.user_profile.type_is_other, - "program_certificates": str(program_certificate_1.program) - + ";" - + str(program_certificate_2.program), - "course_run_certificates": str(course_certificate_1.course_run) - + ";" - + str(course_certificate_2.course_run), } +def test_sync_course_run_certificate_with_hubspot(mocker, settings): + """Course run certificates should be upserted as HubSpot custom objects.""" + cert = CourseRunCertificateFactory.create(is_revoked=False) + settings.HUBSPOT_COURSE_RUN_CERTIFICATE_OBJECT_TYPE = "course_run_certificate" + + mock_find_contact = mocker.patch( + "hubspot_sync.api._find_hubspot_contact_id_by_email", + return_value="contact-123", + ) + mock_assoc_type = mocker.patch( + "hubspot_sync.api._get_cert_contact_association_type_id", + return_value=27, + ) + mock_associate = mocker.patch( + "hubspot_sync.api._associate_certificate_with_contact" + ) + mock_hubspot_api = mocker.patch("hubspot_sync.api.HubspotApi") + mock_client = mock_hubspot_api.return_value + mocker.patch( + "hubspot_sync.api._find_hubspot_certificate_by_unique_app_id", + return_value=None, + ) + created = SimplePublicObjectFactory(id="cert-001") + mock_client.crm.objects.basic_api.create.return_value = created + + result = api.sync_course_run_certificate_with_hubspot(cert) + + assert result == created + mock_client.crm.objects.basic_api.create.assert_called_once() + mock_find_contact.assert_called_once_with(mock_client, cert.user.email) + mock_assoc_type.assert_called_once_with( + mock_client, + "course_run_certificate", + "HUBSPOT_COURSE_RUN_CERTIFICATE_ASSOCIATION_TYPE_ID", + ) + mock_associate.assert_called_once_with( + mock_client, + "course_run_certificate", + "cert-001", + "contact-123", + 27, + ) + + +def test_sync_program_certificate_with_hubspot(mocker, settings): + """Program certificates should be upserted as HubSpot custom objects.""" + cert = ProgramCertificateFactory.create(is_revoked=False) + settings.HUBSPOT_PROGRAM_CERTIFICATE_OBJECT_TYPE = "program_certificate" + + mock_find_contact = mocker.patch( + "hubspot_sync.api._find_hubspot_contact_id_by_email", + return_value="contact-456", + ) + mock_assoc_type = mocker.patch( + "hubspot_sync.api._get_cert_contact_association_type_id", + return_value=28, + ) + mock_associate = mocker.patch( + "hubspot_sync.api._associate_certificate_with_contact" + ) + mock_hubspot_api = mocker.patch("hubspot_sync.api.HubspotApi") + mock_client = mock_hubspot_api.return_value + mocker.patch( + "hubspot_sync.api._find_hubspot_certificate_by_unique_app_id", + return_value=None, + ) + created = SimplePublicObjectFactory(id="cert-002") + mock_client.crm.objects.basic_api.create.return_value = created + + result = api.sync_program_certificate_with_hubspot(cert) + + assert result == created + mock_client.crm.objects.basic_api.create.assert_called_once() + mock_find_contact.assert_called_once_with(mock_client, cert.user.email) + mock_assoc_type.assert_called_once_with( + mock_client, + "program_certificate", + "HUBSPOT_PROGRAM_CERTIFICATE_ASSOCIATION_TYPE_ID", + ) + mock_associate.assert_called_once_with( + mock_client, + "program_certificate", + "cert-002", + "contact-456", + 28, + ) + + @pytest.mark.django_db def test_make_contact_sync_message_minimal_user(mocker): """ diff --git a/hubspot_sync/management/commands/create_hubspot_certificate_schema.py b/hubspot_sync/management/commands/create_hubspot_certificate_schema.py new file mode 100644 index 0000000000..dafecceff6 --- /dev/null +++ b/hubspot_sync/management/commands/create_hubspot_certificate_schema.py @@ -0,0 +1,117 @@ +"""Create HubSpot custom object schemas for certificate syncing.""" + +from django.conf import settings +from django.core.management import BaseCommand, CommandError +from mitol.hubspot_api.api import HubspotApi, HubspotObjectType + +from hubspot_sync.api import CERTIFICATE_CUSTOM_OBJECT_SCHEMAS +from hubspot_sync.rate_limiter import wait_for_hubspot_rate_limit + + +class Command(BaseCommand): + """Create or verify certificate custom object schemas in HubSpot.""" + + help = ( + "Create HubSpot custom object schemas for course/program certificates and " + "print objectTypeId and contact association type IDs." + ) + + def handle(self, *args, **options): # noqa: ARG002 + token = settings.MITOL_HUBSPOT_API_PRIVATE_TOKEN + if not token: + raise CommandError("MITOL_HUBSPOT_API_PRIVATE_TOKEN is not configured") + + hubspot_client = HubspotApi(access_token=token) + + wait_for_hubspot_rate_limit() + existing_schemas = hubspot_client.crm.schemas.core_api.get_all() + existing_by_name = { + schema.name: schema for schema in getattr(existing_schemas, "results", []) + } + + created_or_existing = {} + + for schema_name, schema_payload in CERTIFICATE_CUSTOM_OBJECT_SCHEMAS.items(): + if schema_name in existing_by_name: + schema = existing_by_name[schema_name] + self.stdout.write( + self.style.WARNING( + f"Schema {schema_name} already exists (objectTypeId={schema.object_type_id})" + ) + ) + created_or_existing[schema_name] = schema + continue + + wait_for_hubspot_rate_limit() + # Add the name field to the payload for HubSpot API + payload_with_name = {"name": schema_name, **schema_payload} + try: + schema = hubspot_client.crm.schemas.core_api.create(payload_with_name) + self.stdout.write( + self.style.SUCCESS( + f"Created schema {schema_name} (objectTypeId={schema.object_type_id})" + ) + ) + created_or_existing[schema_name] = schema + except Exception as e: + self.stdout.write( + self.style.ERROR(f"Failed to create schema {schema_name}: {e}") + ) + raise + + self.stdout.write("\nCertificate schema details:\n") + env_output = [] + + for schema_name, schema in created_or_existing.items(): + object_type_id = schema.object_type_id + + # Try to fetch the association type ID, but don't fail if unavailable + assoc_type_id = None + try: + wait_for_hubspot_rate_limit() + assoc_types = hubspot_client.crm.associations.v4.definition_api.get_all( + from_object_type=object_type_id, + to_object_type=HubspotObjectType.CONTACTS.value, + ) + if getattr(assoc_types, "results", None): + assoc_type_id = assoc_types.results[0].type_id + except Exception as e: + self.stdout.write( + self.style.WARNING( + f"Could not fetch association type ID for {schema_name}: {e}" + ) + ) + + self.stdout.write(f"- {schema_name}") + self.stdout.write(f" objectTypeId: {object_type_id}") + if assoc_type_id: + self.stdout.write(f" contact association typeId: {assoc_type_id}") + else: + self.stdout.write( + " contact association typeId: (will need to be set manually or auto-created on first sync)" + ) + + if schema_name == "course_run_certificate": + env_output.extend( + [ + f"HUBSPOT_COURSE_RUN_CERTIFICATE_OBJECT_TYPE={schema_name}", + ] + ) + if assoc_type_id: + env_output.append( + f"HUBSPOT_COURSE_RUN_CERTIFICATE_ASSOCIATION_TYPE_ID={assoc_type_id}" + ) + elif schema_name == "program_certificate": + env_output.extend( + [ + f"HUBSPOT_PROGRAM_CERTIFICATE_OBJECT_TYPE={schema_name}", + ] + ) + if assoc_type_id: + env_output.append( + f"HUBSPOT_PROGRAM_CERTIFICATE_ASSOCIATION_TYPE_ID={assoc_type_id}" + ) + + self.stdout.write("\nAdd/update these environment settings:\n") + for env_line in env_output: + self.stdout.write(env_line) diff --git a/hubspot_sync/serializers.py b/hubspot_sync/serializers.py index 42e03ea5fb..b693f921d0 100644 --- a/hubspot_sync/serializers.py +++ b/hubspot_sync/serializers.py @@ -8,10 +8,8 @@ from courses.models import ( CourseRun, - CourseRunCertificate, CourseRunEnrollment, Program, - ProgramCertificate, ProgramEnrollment, ) from ecommerce import models @@ -316,37 +314,8 @@ class Meta: class HubspotContactSerializer(UserSerializer): """User Serializer for Hubspot""" - program_certificates = serializers.SerializerMethodField() - course_run_certificates = serializers.SerializerMethodField() - - def get_program_certificates(self, instance): - """Return a list of program names that the user has a certificate for.""" - programs_user_has_cert = ProgramCertificate.objects.filter( - user=instance, is_revoked=False - ).select_related("program") - program_name_array = [ - str(program_cert.program).replace(";", "") - for program_cert in programs_user_has_cert - ] - return ";".join(program_name_array) - - def get_course_run_certificates(self, instance): - """Return a list of course run names that the user has a certificate for.""" - course_runs_user_has_cert = CourseRunCertificate.objects.filter( - user=instance, is_revoked=False - ).select_related("course_run") - course_run_name_array = [ - str(course_run_cert.course_run).replace(";", "") - for course_run_cert in course_runs_user_has_cert - ] - return ";".join(course_run_name_array) - class Meta: - fields = ( - *UserSerializer.Meta.fields, - "program_certificates", - "course_run_certificates", - ) + fields = (*UserSerializer.Meta.fields,) read_only_fields = fields model = models.User diff --git a/hubspot_sync/serializers_test.py b/hubspot_sync/serializers_test.py index 07ef02aa19..51ee2b2134 100644 --- a/hubspot_sync/serializers_test.py +++ b/hubspot_sync/serializers_test.py @@ -5,7 +5,6 @@ # 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 @@ -14,11 +13,8 @@ from mitol.hubspot_api.models import HubspotObject from courses.factories import ( - CourseRunCertificateFactory, CourseRunEnrollmentFactory, CourseRunFactory, - ProgramCertificateFactory, - ProgramFactory, ) from ecommerce.constants import ( DISCOUNT_TYPE_DOLLARS_OFF, @@ -33,7 +29,6 @@ from ecommerce.models import OrderStatus, Product from hubspot_sync.serializers import ( ORDER_STATUS_MAPPING, - HubspotContactSerializer, LineSerializer, OrderToDealSerializer, ProductSerializer, @@ -217,96 +212,3 @@ def test_serialize_order_with_coupon( # noqa: PLR0913 "pipeline": settings.HUBSPOT_PIPELINE_ID, "unique_app_id": expected_unique_app_id, } - - -@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("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) - course_run_cert_2 = CourseRunCertificateFactory.create(user=user) - serialized_data = HubspotContactSerializer(instance=user).data - assert ( - serialized_data["program_certificates"] - == f"{program_cert_1.program!s};{program_cert_2.program!s}" - ) - assert ( - 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 diff --git a/hubspot_sync/tasks.py b/hubspot_sync/tasks.py index 58f336d939..903fc05f10 100644 --- a/hubspot_sync/tasks.py +++ b/hubspot_sync/tasks.py @@ -242,6 +242,42 @@ def sync_deal_with_hubspot_targeted(order_id: int, *, is_uai: bool) -> str | Non return result.id if result else None +@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_course_run_certificate_with_hubspot(cert_id: int) -> str | None: + """Sync a CourseRunCertificate to a HubSpot custom object record.""" + from courses.models import CourseRunCertificate # noqa: PLC0415 + + cert = CourseRunCertificate.all_objects.get(id=cert_id) + result = api.sync_course_run_certificate_with_hubspot(cert) + return result.id if result else None + + +@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_program_certificate_with_hubspot(cert_id: int) -> str | None: + """Sync a ProgramCertificate to a HubSpot custom object record.""" + from courses.models import ProgramCertificate # noqa: PLC0415 + + cert = ProgramCertificate.all_objects.get(id=cert_id) + result = api.sync_program_certificate_with_hubspot(cert) + return result.id if result else None + + @app.task( acks_late=True, autoretry_for=(TooManyRequestsException, BlockingIOError), diff --git a/hubspot_sync/tasks_test.py b/hubspot_sync/tasks_test.py index 115db4b7e3..20facb2e98 100644 --- a/hubspot_sync/tasks_test.py +++ b/hubspot_sync/tasks_test.py @@ -22,6 +22,7 @@ from reversion.models import Version from b2b.factories import ContractPageFactory +from courses.factories import CourseRunCertificateFactory, ProgramCertificateFactory from ecommerce.factories import LineFactory, OrderFactory, ProductFactory from ecommerce.models import Order, Product from hubspot_sync import tasks @@ -34,9 +35,11 @@ batch_upsert_associations_chunked, sync_cart_add_event_with_hubspot, sync_contact_with_hubspot, + sync_course_run_certificate_with_hubspot, sync_deal_with_hubspot, sync_deal_with_hubspot_targeted, sync_product_with_hubspot, + sync_program_certificate_with_hubspot, ) from users.factories import UserFactory from users.models import User @@ -90,6 +93,34 @@ def test_task_sync_deal_with_hubspot(mocker): mock_api_call.assert_called_once_with(mock_object) +def test_task_sync_course_run_certificate_with_hubspot(mocker): + """Course run certificate task should call API and return hubspot id.""" + cert = CourseRunCertificateFactory.create() + mock_result = SimplePublicObjectFactory() + + mock_api_call = mocker.patch( + "hubspot_sync.tasks.api.sync_course_run_certificate_with_hubspot", + return_value=mock_result, + ) + + assert sync_course_run_certificate_with_hubspot(cert.id) == mock_result.id + mock_api_call.assert_called_once() + + +def test_task_sync_program_certificate_with_hubspot(mocker): + """Program certificate task should call API and return hubspot id.""" + cert = ProgramCertificateFactory.create() + mock_result = SimplePublicObjectFactory() + + mock_api_call = mocker.patch( + "hubspot_sync.tasks.api.sync_program_certificate_with_hubspot", + return_value=mock_result, + ) + + assert sync_program_certificate_with_hubspot(cert.id) == mock_result.id + mock_api_call.assert_called_once() + + 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() diff --git a/main/settings.py b/main/settings.py index b8885c7654..7a07c6edc7 100644 --- a/main/settings.py +++ b/main/settings.py @@ -1392,6 +1392,26 @@ default=60, description="Number of milliseconds to wait between consecutive Hubspot calls", ) +HUBSPOT_COURSE_RUN_CERTIFICATE_OBJECT_TYPE = get_string( + name="HUBSPOT_COURSE_RUN_CERTIFICATE_OBJECT_TYPE", + default="course_run_certificate", + description="HubSpot custom object type name for course run certificates", +) +HUBSPOT_PROGRAM_CERTIFICATE_OBJECT_TYPE = get_string( + name="HUBSPOT_PROGRAM_CERTIFICATE_OBJECT_TYPE", + default="program_certificate", + description="HubSpot custom object type name for program certificates", +) +HUBSPOT_COURSE_RUN_CERTIFICATE_ASSOCIATION_TYPE_ID = get_string( + name="HUBSPOT_COURSE_RUN_CERTIFICATE_ASSOCIATION_TYPE_ID", + default=None, + description="HubSpot association type ID for course_run_certificate -> contact (set after schema registration)", +) +HUBSPOT_PROGRAM_CERTIFICATE_ASSOCIATION_TYPE_ID = get_string( + name="HUBSPOT_PROGRAM_CERTIFICATE_ASSOCIATION_TYPE_ID", + default=None, + description="HubSpot association type ID for program_certificate -> contact (set after schema registration)", +) # HomePage Hubspot Form Settings HUBSPOT_HOME_PAGE_FORM_GUID = get_string(