From 67143847508a492c25a3c188922b012076d48945 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Mon, 9 Sep 2024 09:02:30 -0400 Subject: [PATCH] feat: Add hubspot cookie tracking (#4539) Co-authored-by: Kim Gustyr --- .../lead_tracking/hubspot/client.py | 73 +++++++++++++++++-- .../lead_tracking/hubspot/constants.py | 4 + .../lead_tracking/hubspot/lead_tracker.py | 7 +- .../lead_tracking/hubspot/services.py | 16 ++++ api/organisations/invites/views.py | 7 ++ api/organisations/views.py | 5 ++ .../hubspot/test_unit_hubspot_client.py | 63 ++++++++++++++++ .../test_unit_hubspot_lead_tracking.py | 18 ++++- .../test_unit_organisations_views.py | 6 ++ api/tests/unit/users/test_unit_users_views.py | 9 ++- .../migrations/0038_create_hubspot_tracker.py | 41 +++++++++++ api/users/models.py | 11 +++ 12 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 api/integrations/lead_tracking/hubspot/constants.py create mode 100644 api/integrations/lead_tracking/hubspot/services.py create mode 100644 api/users/migrations/0038_create_hubspot_tracker.py diff --git a/api/integrations/lead_tracking/hubspot/client.py b/api/integrations/lead_tracking/hubspot/client.py index 60a7328267e6..ea5118c4f844 100644 --- a/api/integrations/lead_tracking/hubspot/client.py +++ b/api/integrations/lead_tracking/hubspot/client.py @@ -1,6 +1,9 @@ +import json import logging +from typing import Any import hubspot +import requests from django.conf import settings from hubspot.crm.companies import ( PublicObjectSearchRequest, @@ -9,6 +12,11 @@ ) from hubspot.crm.contacts import BatchReadInputSimplePublicObjectId +from integrations.lead_tracking.hubspot.constants import ( + HUBSPOT_FORM_ID, + HUBSPOT_PORTAL_ID, + HUBSPOT_ROOT_FORM_URL, +) from users.models import FFAdminUser logger = logging.getLogger(__name__) @@ -16,10 +24,10 @@ class HubspotClient: def __init__(self, client: hubspot.Client = None) -> None: - access_token = settings.HUBSPOT_ACCESS_TOKEN - self.client = client or hubspot.Client.create(access_token=access_token) + self.access_token = settings.HUBSPOT_ACCESS_TOKEN + self.client = client or hubspot.Client.create(access_token=self.access_token) - def get_contact(self, user: FFAdminUser) -> None | dict: + def get_contact(self, user: FFAdminUser) -> None | dict[str, Any]: public_object_id = BatchReadInputSimplePublicObjectId( id_property="email", inputs=[{"id": user.email}], @@ -42,7 +50,56 @@ def get_contact(self, user: FFAdminUser) -> None | dict: return results[0] - def create_contact(self, user: FFAdminUser, hubspot_company_id: str) -> dict: + def create_lead_form( + self, user: FFAdminUser, hubspot_cookie: str + ) -> dict[str, Any]: + fields = [ + { + "objectTypeId": "0-1", + "name": "email", + "value": user.email, + }, + {"objectTypeId": "0-1", "name": "firstname", "value": user.first_name}, + {"objectTypeId": "0-1", "name": "lastname", "value": user.last_name}, + ] + + context = { + "hutk": hubspot_cookie, + "pageUri": "www.flagsmith.com", + "pageName": "Homepage", + } + + legal = { + "consent": { + "consentToProcess": True, + "text": "I agree to allow Flagsmith to store and process my personal data.", + "communications": [ + { + "value": user.marketing_consent_given, + "subscriptionTypeId": 999, + "text": "I agree to receive marketing communications from Flagsmith.", + } + ], + } + } + payload = {"legalConsentOptions": legal, "context": context, "fields": fields} + headers = { + "Content-Type": "application/json", + } + url = f"{HUBSPOT_ROOT_FORM_URL}/{HUBSPOT_PORTAL_ID}/{HUBSPOT_FORM_ID}" + + response = requests.post(url, headers=headers, data=json.dumps(payload)) + + if response.status_code not in {200, 201}: + logger.error( + f"Problem posting data to Hubspot's form API due to {response.status_code} " + f"status code and following response: {response.text}" + ) + return response.json() + + def create_contact( + self, user: FFAdminUser, hubspot_company_id: str + ) -> dict[str, Any]: properties = { "email": user.email, "firstname": user.first_name, @@ -68,7 +125,7 @@ def create_contact(self, user: FFAdminUser, hubspot_company_id: str) -> dict: ) return response.to_dict() - def get_company_by_domain(self, domain: str) -> dict | None: + def get_company_by_domain(self, domain: str) -> dict[str, Any] | None: """ Domain should be unique in Hubspot by design, so we should only ever have 0 or 1 results. @@ -102,7 +159,7 @@ def create_company( active_subscription: str = None, organisation_id: int = None, domain: str | None = None, - ) -> dict: + ) -> dict[str, Any]: properties = {"name": name} if domain: @@ -122,7 +179,9 @@ def create_company( return response.to_dict() - def update_company(self, active_subscription: str, hubspot_company_id: str) -> dict: + def update_company( + self, active_subscription: str, hubspot_company_id: str + ) -> dict[str, Any]: properties = { "active_subscription": active_subscription, } diff --git a/api/integrations/lead_tracking/hubspot/constants.py b/api/integrations/lead_tracking/hubspot/constants.py new file mode 100644 index 000000000000..801920edb045 --- /dev/null +++ b/api/integrations/lead_tracking/hubspot/constants.py @@ -0,0 +1,4 @@ +HUBSPOT_COOKIE_NAME = "hubspotutk" +HUBSPOT_PORTAL_ID = "143451822" +HUBSPOT_FORM_ID = "562ee023-fb3f-4645-a217-4d8c9b4e45be" +HUBSPOT_ROOT_FORM_URL = "https://api.hsforms.com/submissions/v3/integration/submit" diff --git a/api/integrations/lead_tracking/hubspot/lead_tracker.py b/api/integrations/lead_tracking/hubspot/lead_tracker.py index 2bc1f5ca5cae..b7347443d76c 100644 --- a/api/integrations/lead_tracking/hubspot/lead_tracker.py +++ b/api/integrations/lead_tracking/hubspot/lead_tracker.py @@ -8,7 +8,7 @@ Organisation, Subscription, ) -from users.models import FFAdminUser, HubspotLead +from users.models import FFAdminUser, HubspotLead, HubspotTracker from .client import HubspotClient @@ -58,6 +58,11 @@ def create_lead(self, user: FFAdminUser, organisation: Organisation = None) -> N HubspotLead.objects.create(user=user, hubspot_id=response["id"]) + if tracker := HubspotTracker.objects.filter(user=user).first(): + self.client.create_lead_form( + user=user, hubspot_cookie=tracker.hubspot_cookie + ) + def get_or_create_organisation_hubspot_id( self, user: FFAdminUser, organisation: Organisation = None ) -> str: diff --git a/api/integrations/lead_tracking/hubspot/services.py b/api/integrations/lead_tracking/hubspot/services.py new file mode 100644 index 000000000000..7cc3f7cebcb8 --- /dev/null +++ b/api/integrations/lead_tracking/hubspot/services.py @@ -0,0 +1,16 @@ +from rest_framework.request import Request + +from integrations.lead_tracking.hubspot.constants import HUBSPOT_COOKIE_NAME +from users.models import HubspotTracker + + +def register_hubspot_tracker(request: Request) -> None: + hubspot_cookie = request.COOKIES.get(HUBSPOT_COOKIE_NAME) + + if hubspot_cookie: + HubspotTracker.objects.update_or_create( + user=request.user, + defaults={ + "hubspot_cookie": hubspot_cookie, + }, + ) diff --git a/api/organisations/invites/views.py b/api/organisations/invites/views.py index 7204601b0bf4..c06b2141d4f0 100644 --- a/api/organisations/invites/views.py +++ b/api/organisations/invites/views.py @@ -14,6 +14,9 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.viewsets import GenericViewSet +from integrations.lead_tracking.hubspot.services import ( + register_hubspot_tracker, +) from organisations.invites.exceptions import InviteExpiredError from organisations.invites.models import Invite, InviteLink from organisations.invites.serializers import ( @@ -44,6 +47,8 @@ def join_organisation_from_email(request, hash): error_data = {"detail": str(e)} return Response(data=error_data, status=status.HTTP_400_BAD_REQUEST) + register_hubspot_tracker(request) + return Response( OrganisationSerializerFull( invite.organisation, context={"request": request} @@ -62,6 +67,8 @@ def join_organisation_from_link(request, hash): if invite.is_expired: raise InviteExpiredError() + register_hubspot_tracker(request) + request.user.join_organisation_from_invite_link(invite) return Response( diff --git a/api/organisations/views.py b/api/organisations/views.py index aeb63359881f..35afd1b66095 100644 --- a/api/organisations/views.py +++ b/api/organisations/views.py @@ -22,6 +22,9 @@ from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle +from integrations.lead_tracking.hubspot.services import ( + register_hubspot_tracker, +) from organisations.chargebee import webhook_event_types, webhook_handlers from organisations.exceptions import OrganisationHasNoPaidSubscription from organisations.models import ( @@ -109,6 +112,8 @@ def create(self, request, **kwargs): """ Override create method to add new organisation to authenticated user """ + + register_hubspot_tracker(request) user = request.user serializer = OrganisationSerializerFull(data=request.data) if serializer.is_valid(): diff --git a/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py b/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py index 541ae6d143b1..d76ebc7689e7 100644 --- a/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py +++ b/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py @@ -1,12 +1,22 @@ +import logging + import pytest +import responses from pytest_mock import MockerFixture +from rest_framework import status from integrations.lead_tracking.hubspot.client import HubspotClient +from integrations.lead_tracking.hubspot.constants import ( + HUBSPOT_FORM_ID, + HUBSPOT_PORTAL_ID, + HUBSPOT_ROOT_FORM_URL, +) from tests.unit.integrations.lead_tracking.hubspot._hubspot_responses import ( generate_create_company_response, generate_get_company_by_domain_response, generate_get_company_by_domain_response_no_results, ) +from users.models import FFAdminUser @pytest.fixture() @@ -14,6 +24,59 @@ def hubspot_client(mocker: MockerFixture) -> HubspotClient: return HubspotClient(client=mocker.MagicMock()) +@responses.activate +def test_create_lead_form( + staff_user: FFAdminUser, + hubspot_client: HubspotClient, +) -> None: + # Given + hubspot_cookie = "test_hubspot_cookie" + url = f"{HUBSPOT_ROOT_FORM_URL}/{HUBSPOT_PORTAL_ID}/{HUBSPOT_FORM_ID}" + responses.add( + method="POST", + url=url, + status=status.HTTP_200_OK, + json={"inlineMessage": "Thanks for submitting the form."}, + ) + + # When + response = hubspot_client.create_lead_form(staff_user, hubspot_cookie) + + # Then + assert response == {"inlineMessage": "Thanks for submitting the form."} + + +@responses.activate +def test_create_lead_form_error( + staff_user: FFAdminUser, + hubspot_client: HubspotClient, + inspecting_handler: logging.Handler, +) -> None: + # Given + from integrations.lead_tracking.hubspot.client import logger + + logger.addHandler(inspecting_handler) + + hubspot_cookie = "test_hubspot_cookie" + url = f"{HUBSPOT_ROOT_FORM_URL}/{HUBSPOT_PORTAL_ID}/{HUBSPOT_FORM_ID}" + responses.add( + method="POST", + url=url, + status=status.HTTP_400_BAD_REQUEST, + json={"error": "Problem processing."}, + ) + + # When + response = hubspot_client.create_lead_form(staff_user, hubspot_cookie) + + # Then + assert response == {"error": "Problem processing."} + assert inspecting_handler.messages == [ + "Problem posting data to Hubspot's form API due to 400 status code and following response: " + + '{"error": "Problem processing."}' + ] + + def test_get_company_by_domain(hubspot_client: HubspotClient) -> None: # Given name = "Flagsmith" 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 index 66019231ec6d..587cbf6ca0a2 100644 --- 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 @@ -1,17 +1,25 @@ import datetime +import responses from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture +from rest_framework import status from task_processor.task_run_method import TaskRunMethod +from integrations.lead_tracking.hubspot.constants import ( + HUBSPOT_FORM_ID, + HUBSPOT_PORTAL_ID, + HUBSPOT_ROOT_FORM_URL, +) from organisations.models import ( HubspotOrganisation, Organisation, OrganisationRole, ) -from users.models import FFAdminUser, HubspotLead +from users.models import FFAdminUser, HubspotLead, HubspotTracker +@responses.activate def test_hubspot_with_new_contact_and_new_organisation( organisation: Organisation, settings: SettingsWrapper, @@ -26,7 +34,15 @@ def test_hubspot_with_new_contact_and_new_organisation( last_name="Louis", marketing_consent_given=True, ) + url = f"{HUBSPOT_ROOT_FORM_URL}/{HUBSPOT_PORTAL_ID}/{HUBSPOT_FORM_ID}" + responses.add( + method="POST", + url=url, + status=status.HTTP_200_OK, + json={"inlineMessage": "Thanks for submitting the form."}, + ) + HubspotTracker.objects.create(user=user, hubspot_cookie="tracker") future_hubspot_id = "10280696017" mock_create_company = mocker.patch( "integrations.lead_tracking.hubspot.client.HubspotClient.create_company", diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 79489b9d6c8a..ceae4eb079cb 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -23,6 +23,7 @@ from environments.models import Environment from environments.permissions.models import UserEnvironmentPermission from features.models import Feature +from integrations.lead_tracking.hubspot.constants import HUBSPOT_COOKIE_NAME from organisations.chargebee.metadata import ChargebeeObjMetadata from organisations.invites.models import Invite from organisations.models import ( @@ -46,6 +47,7 @@ from segments.models import Segment from users.models import ( FFAdminUser, + HubspotTracker, UserPermissionGroup, UserPermissionGroupMembership, ) @@ -69,6 +71,7 @@ def test_should_return_organisation_list_when_requested( def test_non_superuser_can_create_new_organisation_by_default( staff_client: APIClient, + staff_user: FFAdminUser, ) -> None: # Given org_name = "Test create org" @@ -78,6 +81,8 @@ def test_non_superuser_can_create_new_organisation_by_default( "name": org_name, "webhook_notification_email": webhook_notification_email, } + staff_client.cookies[HUBSPOT_COOKIE_NAME] = "test_cookie_tracker" + assert not HubspotTracker.objects.filter(user=staff_user).exists() # When response = staff_client.post(url, data=data) @@ -88,6 +93,7 @@ def test_non_superuser_can_create_new_organisation_by_default( Organisation.objects.get(name=org_name).webhook_notification_email == webhook_notification_email ) + assert HubspotTracker.objects.filter(user=staff_user).exists() @override_settings(RESTRICT_ORG_CREATE_TO_SUPERUSERS=True) diff --git a/api/tests/unit/users/test_unit_users_views.py b/api/tests/unit/users/test_unit_users_views.py index 794a1b41bdcc..2a65de5b43ae 100644 --- a/api/tests/unit/users/test_unit_users_views.py +++ b/api/tests/unit/users/test_unit_users_views.py @@ -16,9 +16,10 @@ from rest_framework import status from rest_framework.test import APIClient +from integrations.lead_tracking.hubspot.constants import HUBSPOT_COOKIE_NAME from organisations.invites.models import Invite, InviteLink from organisations.models import Organisation, OrganisationRole -from users.models import FFAdminUser, UserPermissionGroup +from users.models import FFAdminUser, HubspotTracker, UserPermissionGroup def test_join_organisation( @@ -29,6 +30,8 @@ def test_join_organisation( organisation = Organisation.objects.create(name="test org") invite = Invite.objects.create(email=staff_user.email, organisation=organisation) url = reverse("api-v1:users:user-join-organisation", args=[invite.hash]) + staff_client.cookies[HUBSPOT_COOKIE_NAME] = "test_cookie_tracker" + assert not HubspotTracker.objects.filter(user=staff_user).exists() # When response = staff_client.post(url) @@ -37,6 +40,7 @@ def test_join_organisation( # Then assert response.status_code == status.HTTP_200_OK assert organisation in staff_user.organisations.all() + assert HubspotTracker.objects.filter(user=staff_user).exists() def test_join_organisation_via_link( @@ -47,6 +51,8 @@ def test_join_organisation_via_link( organisation = Organisation.objects.create(name="test org") invite = InviteLink.objects.create(organisation=organisation) url = reverse("api-v1:users:user-join-organisation-link", args=[invite.hash]) + staff_client.cookies[HUBSPOT_COOKIE_NAME] = "test_cookie_tracker" + assert not HubspotTracker.objects.filter(user=staff_user).exists() # When response = staff_client.post(url) @@ -55,6 +61,7 @@ def test_join_organisation_via_link( # Then assert response.status_code == status.HTTP_200_OK assert organisation in staff_user.organisations.all() + assert HubspotTracker.objects.filter(user=staff_user).exists() def test_cannot_join_organisation_via_expired_link( diff --git a/api/users/migrations/0038_create_hubspot_tracker.py b/api/users/migrations/0038_create_hubspot_tracker.py new file mode 100644 index 000000000000..9c69ad26e709 --- /dev/null +++ b/api/users/migrations/0038_create_hubspot_tracker.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.25 on 2024-08-26 15:09 + + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0037_add_uuid_field_to_user_model"), + ] + + operations = [ + migrations.CreateModel( + name="HubspotTracker", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("hubspot_cookie", models.CharField(max_length=100, unique=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="hubspot_tracker", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/api/users/models.py b/api/users/models.py index f370b00dffab..cb3f30227e21 100644 --- a/api/users/models.py +++ b/api/users/models.py @@ -440,3 +440,14 @@ class HubspotLead(models.Model): hubspot_id = models.CharField(unique=True, max_length=100, null=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + + +class HubspotTracker(models.Model): + user = models.OneToOneField( + FFAdminUser, + related_name="hubspot_tracker", + on_delete=models.CASCADE, + ) + hubspot_cookie = models.CharField(unique=True, max_length=100, null=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True)