diff --git a/api/app/settings/common.py b/api/app/settings/common.py index ca976297ef68..0529ff58a4a6 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1032,6 +1032,13 @@ "PIPEDRIVE_LEAD_LABEL_EXISTING_CUSTOMER_ID", None ) +# Hubspot settings +HUBSPOT_ACCESS_TOKEN = env.str("HUBSPOT_ACCESS_TOKEN", None) +ENABLE_HUBSPOT_LEAD_TRACKING = env.bool("ENABLE_HUBSPOT_LEAD_TRACKING", False) +HUBSPOT_IGNORE_DOMAINS = env.list("HUBSPOT_IGNORE_DOMAINS", []) +HUBSPOT_IGNORE_DOMAINS_REGEX = env("HUBSPOT_IGNORE_DOMAINS_REGEX", "") + + # List of plan ids that support seat upgrades AUTO_SEAT_UPGRADE_PLANS = env.list("AUTO_SEAT_UPGRADE_PLANS", default=[]) diff --git a/api/integrations/lead_tracking/hubspot/client.py b/api/integrations/lead_tracking/hubspot/client.py new file mode 100644 index 000000000000..6e96528154ed --- /dev/null +++ b/api/integrations/lead_tracking/hubspot/client.py @@ -0,0 +1,77 @@ +import logging + +import hubspot +from django.conf import settings +from hubspot.crm.companies import SimplePublicObjectInputForCreate +from hubspot.crm.contacts import BatchReadInputSimplePublicObjectId + +from users.models import FFAdminUser + +logger = logging.getLogger(__name__) + + +class HubspotClient: + def __init__(self) -> None: + access_token = settings.HUBSPOT_ACCESS_TOKEN + self.client = hubspot.Client.create(access_token=access_token) + + def get_contact(self, user: FFAdminUser) -> None | dict: + public_object_id = BatchReadInputSimplePublicObjectId( + id_property="email", + inputs=[{"id": user.email}], + properties=["email", "firstname", "lastname"], + ) + + response = self.client.crm.contacts.batch_api.read( + batch_read_input_simple_public_object_id=public_object_id, + archived=False, + ) + + results = response.to_dict()["results"] + if not results: + return None + + if len(results) > 1: + logger.warning( + "Hubspot contact endpoint is non-unique which should not be possible" + ) + + return results[0] + + def create_contact(self, user: FFAdminUser, hubspot_company_id: str) -> dict: + properties = { + "email": user.email, + "firstname": user.first_name, + "lastname": user.last_name, + "hs_marketable_status": user.marketing_consent_given, + } + + response = self.client.crm.contacts.basic_api.create( + simple_public_object_input_for_create=SimplePublicObjectInputForCreate( + properties=properties, + associations=[ + { + "types": [ + { + "associationCategory": "HUBSPOT_DEFINED", + "associationTypeId": 1, + } + ], + "to": {"id": hubspot_company_id}, + } + ], + ) + ) + return response.to_dict() + + def create_company(self, name: str) -> dict: + properties = {"name": name} + simple_public_object_input_for_create = SimplePublicObjectInputForCreate( + properties=properties, + ) + + response = self.client.crm.companies.basic_api.create( + simple_public_object_input_for_create=simple_public_object_input_for_create, + ) + + return response.to_dict() diff --git a/api/integrations/lead_tracking/hubspot/lead_tracker.py b/api/integrations/lead_tracking/hubspot/lead_tracker.py new file mode 100644 index 000000000000..f6122ecc4678 --- /dev/null +++ b/api/integrations/lead_tracking/hubspot/lead_tracker.py @@ -0,0 +1,78 @@ +import logging + +from django.conf import settings + +from integrations.lead_tracking.lead_tracking import LeadTracker +from organisations.models import HubspotOrganisation, Organisation +from users.models import FFAdminUser + +from .client import HubspotClient + +logger = logging.getLogger(__name__) + +try: + import re2 as re + + logger.info("Using re2 library for regex.") +except ImportError: + logger.warning("Unable to import re2. Falling back to re.") + import re + + +class HubspotLeadTracker(LeadTracker): + @staticmethod + def should_track(user: FFAdminUser) -> bool: + if not settings.ENABLE_HUBSPOT_LEAD_TRACKING: + return False + + domain = user.email_domain + + if settings.HUBSPOT_IGNORE_DOMAINS_REGEX and re.match( + settings.HUBSPOT_IGNORE_DOMAINS_REGEX, domain + ): + return False + + if ( + settings.HUBSPOT_IGNORE_DOMAINS + and domain in settings.HUBSPOT_IGNORE_DOMAINS + ): + return False + + if any( + org.is_paid + for org in user.organisations.select_related("subscription").all() + ): + return False + + return True + + def create_lead(self, user: FFAdminUser, organisation: Organisation) -> None: + contact_data = self.client.get_contact(user) + + if contact_data: + # The user is already present in the system as a lead + # for an existing organisation, so return early. + return + + hubspot_id = self.get_or_create_organisation_hubspot_id(organisation) + + self.client.create_contact(user, hubspot_id) + + def get_or_create_organisation_hubspot_id(self, organisation: Organisation) -> str: + """ + Return the Hubspot API's id for an organisation. + """ + if getattr(organisation, "hubspot_organisation", None): + return organisation.hubspot_organisation.hubspot_id + + response = self.client.create_company(name=organisation.name) + # Store the organisation data in the database since we are + # unable to look them up via a unique identifier. + HubspotOrganisation.objects.create( + organisation=organisation, + hubspot_id=response["id"], + ) + return response["id"] + + def _get_client(self) -> HubspotClient: + return HubspotClient() diff --git a/api/integrations/lead_tracking/hubspot/tasks.py b/api/integrations/lead_tracking/hubspot/tasks.py new file mode 100644 index 000000000000..2b737e82e256 --- /dev/null +++ b/api/integrations/lead_tracking/hubspot/tasks.py @@ -0,0 +1,24 @@ +from django.conf import settings + +from task_processor.decorators import register_task_handler + + +@register_task_handler() +def track_hubspot_lead(user_id: int, organisation_id: int) -> None: + assert settings.ENABLE_HUBSPOT_LEAD_TRACKING + + # Avoid circular imports. + from organisations.models import Organisation + from users.models import FFAdminUser + + from .lead_tracker import HubspotLeadTracker + + user = FFAdminUser.objects.get(id=user_id) + + if not HubspotLeadTracker.should_track(user): + return + + organisation = Organisation.objects.get(id=organisation_id) + + hubspot_lead_tracker = HubspotLeadTracker() + hubspot_lead_tracker.create_lead(user=user, organisation=organisation) diff --git a/api/organisations/migrations/0052_create_hubspot_organisation.py b/api/organisations/migrations/0052_create_hubspot_organisation.py new file mode 100644 index 000000000000..4122860e4490 --- /dev/null +++ b/api/organisations/migrations/0052_create_hubspot_organisation.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.24 on 2024-02-26 19:04 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('organisations', '0051_create_org_api_usage_notification'), + ] + + operations = [ + migrations.CreateModel( + name='HubspotOrganisation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hubspot_id', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('organisation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='hubspot_organisation', to='organisations.organisation')), + ], + ), + ] diff --git a/api/organisations/models.py b/api/organisations/models.py index a85aaff52b21..5b9ff1368d38 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -17,6 +17,7 @@ from simple_history.models import HistoricalRecords from app.utils import is_enterprise, is_saas +from integrations.lead_tracking.hubspot.tasks import track_hubspot_lead from organisations.chargebee import ( get_customer_id_from_subscription_id, get_max_api_calls_for_plan, @@ -179,7 +180,7 @@ def cancel_users(self): ).exclude(id=remaining_seat_holder.id).delete() -class UserOrganisation(models.Model): +class UserOrganisation(LifecycleModelMixin, models.Model): user = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE) organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE) date_joined = models.DateTimeField(auto_now_add=True) @@ -191,6 +192,16 @@ class Meta: "organisation", ) + @hook(AFTER_CREATE) + def register_hubspot_lead_tracking(self): + if settings.ENABLE_HUBSPOT_LEAD_TRACKING: + track_hubspot_lead.delay( + args=( + self.user.id, + self.organisation.id, + ) + ) + class Subscription(LifecycleModelMixin, SoftDeleteExportableModel): # Even though it is not enforced at the database level, @@ -442,3 +453,14 @@ class OranisationAPIUsageNotification(models.Model): created_at = models.DateTimeField(null=True, auto_now_add=True) updated_at = models.DateTimeField(null=True, auto_now=True) + + +class HubspotOrganisation(models.Model): + organisation = models.OneToOneField( + Organisation, + related_name="hubspot_organisation", + on_delete=models.CASCADE, + ) + hubspot_id = models.CharField(max_length=100) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/api/poetry.lock b/api/poetry.lock index dcd4523b90af..97a35e124c47 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -178,8 +178,8 @@ files = [ lazy-object-proxy = ">=1.4.0" typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} wrapt = [ - {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, {version = ">=1.14,<2", markers = "python_version >= \"3.11\""}, + {version = ">=1.11,<2", markers = "python_version < \"3.11\""}, ] [[package]] @@ -801,8 +801,8 @@ openapi-spec-validator = ">=0.2.8,<=0.5.7" packaging = "*" prance = ">=0.18.2" pydantic = [ - {version = ">=1.9.0,<3.0", extras = ["email"], markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, {version = ">=1.10.0,<3.0", extras = ["email"], markers = "python_version >= \"3.11\" and python_version < \"4.0\""}, + {version = ">=1.9.0,<3.0", extras = ["email"], markers = "python_version >= \"3.10\" and python_version < \"3.11\""}, ] PySnooper = ">=0.4.1,<2.0.0" toml = ">=0.10.0,<1.0.0" @@ -1848,6 +1848,26 @@ files = [ [package.dependencies] pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} +[[package]] +name = "hubspot-api-client" +version = "8.2.1" +description = "HubSpot API client" +optional = false +python-versions = ">=3.7" +files = [ + {file = "hubspot-api-client-8.2.1.tar.gz", hash = "sha256:8c3f92674195b105a8c27b46205fdd67078754a334f296ca7c171c040acd8278"}, + {file = "hubspot_api_client-8.2.1-py3-none-any.whl", hash = "sha256:aa1d368690b079a12c4ea0593d13d358177d85566bbb056c8d21075d9fb65a7f"}, +] + +[package.dependencies] +certifi = "*" +python-dateutil = "*" +six = ">=1.10" +urllib3 = ">=1.15" + +[package.extras] +dev = ["black", "pytest"] + [[package]] name = "identify" version = "2.5.26" @@ -3013,8 +3033,8 @@ files = [ astroid = ">=2.14.2,<=2.16.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, + {version = ">=0.2", markers = "python_version < \"3.11\""}, ] isort = ">=4.2.5,<6" mccabe = ">=0.6,<0.8" @@ -3459,7 +3479,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -4477,4 +4496,4 @@ requests = ">=2.7,<3.0" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "b2f93b350a146a3f057596243476fcb4846b942ffbec19159deed930c981407f" +content-hash = "9bd14f46c470e56c8f71c1fdfc199a35ab9004f006eb29d6e3cdf7ebf7002192" diff --git a/api/pyproject.toml b/api/pyproject.toml index 27897858239b..c45076a3b8cd 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -104,6 +104,7 @@ pyngo = "~1.6.0" flagsmith = "^3.4.0" python-gnupg = "^0.5.1" django-redis = "^5.4.0" +hubspot-api-client = "^8.2.1" [tool.poetry.group.auth-controller] optional = true diff --git a/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_lead_tracking.py b/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_lead_tracking.py new file mode 100644 index 000000000000..109f06c80d97 --- /dev/null +++ b/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_lead_tracking.py @@ -0,0 +1,214 @@ +import datetime + +from pytest_django.fixtures import SettingsWrapper +from pytest_mock import MockerFixture + +from organisations.models import ( + HubspotOrganisation, + Organisation, + OrganisationRole, +) +from users.models import FFAdminUser + + +def test_hubspot_with_new_contact_and_new_organisation( + organisation: Organisation, + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.ENABLE_HUBSPOT_LEAD_TRACKING = True + user = FFAdminUser.objects.create( + email="new.user@example.com", + first_name="Frank", + last_name="Louis", + marketing_consent_given=True, + ) + + future_hubspot_id = "10280696017" + mock_create_company = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.create_company", + return_value={ + "id": future_hubspot_id, + "properties": { + "createdate": "2024-02-26T19:41:57.959Z", + "hs_lastmodifieddate": "2024-02-26T19:41:57.959Z", + "hs_object_id": future_hubspot_id, + "hs_object_source": "INTEGRATION", + "hs_object_source_id": "2902325", + "hs_object_source_label": "INTEGRATION", + "name": organisation.name, + }, + "properties_with_history": None, + "created_at": datetime.datetime(2024, 2, 26, 19, 41, 57, 959000), + "updated_at": datetime.datetime(2024, 2, 26, 19, 41, 57, 959000), + "archived": False, + "archived_at": None, + }, + ) + + mock_get_contact = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.get_contact", + return_value=None, + ) + + mock_create_contact = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.create_contact", + return_value={ + "archived": False, + "archived_at": None, + "created_at": datetime.datetime(2024, 2, 26, 20, 2, 50, 69000), + "id": "1000551", + "properties": { + "createdate": "2024-02-26T20:02:50.069Z", + "email": user.email, + "firstname": user.first_name, + "hs_all_contact_vids": "1000551", + "hs_email_domain": "example.com", + "hs_is_contact": "true", + "hs_is_unworked": "true", + "hs_marketable_status": user.marketing_consent_given, + "hs_object_id": "1000551", + "hs_object_source": "INTEGRATION", + "hs_object_source_id": "2902325", + "hs_object_source_label": "INTEGRATION", + "hs_pipeline": "contacts-lifecycle-pipeline", + "lastmodifieddate": "2024-02-26T20:02:50.069Z", + "lastname": user.last_name, + }, + "properties_with_history": None, + "updated_at": datetime.datetime(2024, 2, 26, 20, 2, 50, 69000), + }, + ) + + assert getattr(organisation, "hubspot_organisation", None) is None + # When + user.add_organisation(organisation, role=OrganisationRole.ADMIN) + + # Then + organisation.refresh_from_db() + assert organisation.hubspot_organisation.hubspot_id == future_hubspot_id + mock_create_company.assert_called_once_with(name=organisation.name) + mock_create_contact.assert_called_once_with(user, future_hubspot_id) + mock_get_contact.assert_called_once_with(user) + + +def test_hubspot_with_new_contact_and_existing_organisation( + organisation: Organisation, + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.ENABLE_HUBSPOT_LEAD_TRACKING = True + user = FFAdminUser.objects.create( + email="new.user@example.com", + first_name="Frank", + last_name="Louis", + marketing_consent_given=True, + ) + hubspot_id = "10280696017" + + # Create an existing hubspot organisation to mimic a previous + # successful API call with a different lead. + HubspotOrganisation.objects.create(organisation=organisation, hubspot_id=hubspot_id) + + mock_create_company = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.create_company" + ) + mock_get_contact = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.get_contact", + return_value=None, + ) + + mock_create_contact = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.create_contact", + return_value={ + "archived": False, + "archived_at": None, + "created_at": datetime.datetime(2024, 2, 26, 20, 2, 50, 69000), + "id": "1000551", + "properties": { + "createdate": "2024-02-26T20:02:50.069Z", + "email": user.email, + "firstname": user.first_name, + "hs_all_contact_vids": "1000551", + "hs_email_domain": "example.com", + "hs_is_contact": "true", + "hs_is_unworked": "true", + "hs_marketable_status": user.marketing_consent_given, + "hs_object_id": "1000551", + "hs_object_source": "INTEGRATION", + "hs_object_source_id": "2902325", + "hs_object_source_label": "INTEGRATION", + "hs_pipeline": "contacts-lifecycle-pipeline", + "lastmodifieddate": "2024-02-26T20:02:50.069Z", + "lastname": user.last_name, + }, + "properties_with_history": None, + "updated_at": datetime.datetime(2024, 2, 26, 20, 2, 50, 69000), + }, + ) + + # When + user.add_organisation(organisation, role=OrganisationRole.ADMIN) + + # Then + mock_create_company.assert_not_called() + mock_create_contact.assert_called_once_with(user, hubspot_id) + mock_get_contact.assert_called_once_with(user) + + +def test_hubspot_with_existing_contact_and_new_organisation( + organisation: Organisation, + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + settings.ENABLE_HUBSPOT_LEAD_TRACKING = True + user = FFAdminUser.objects.create( + email="new.user@example.com", + first_name="Frank", + last_name="Louis", + marketing_consent_given=True, + ) + + mock_create_company = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.create_company" + ) + mock_create_contact = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.create_contact" + ) + + mock_get_contact = mocker.patch( + "integrations.lead_tracking.hubspot.client.HubspotClient.get_contact", + return_value=[ + { + "archived": False, + "archived_at": None, + "created_at": datetime.datetime(2024, 2, 26, 20, 2, 50, 69000), + "id": "1000551", + "properties": { + "createdate": "2024-02-26T20:02:50.069Z", + "email": user.email, + "firstname": user.first_name, + "hs_object_id": "1000551", + "lastmodifieddate": "2024-02-26T20:03:25.254Z", + "lastname": user.last_name, + }, + "properties_with_history": None, + "updated_at": datetime.datetime(2024, 2, 26, 20, 3, 25, 254000), + } + ], + ) + + # When + user.add_organisation(organisation, role=OrganisationRole.ADMIN) + + # Then + organisation.refresh_from_db() + assert getattr(organisation, "hubspot_organisation", None) is None + mock_get_contact.assert_called_once_with(user) + + # Since the user already exists as a lead, don't create any + # further hubspot resources. + mock_create_company.assert_not_called() + mock_create_contact.assert_not_called()