Skip to content

Commit 251508a

Browse files
Zaimwa9matthewelwellpre-commit-ci[bot]
authored
fix: hubspot attribution issues (#5560)
Co-authored-by: Matthew Elwell <matthew.elwell@flagsmith.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 23aa20f commit 251508a

18 files changed

Lines changed: 724 additions & 552 deletions

File tree

api/custom_auth/views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
from custom_auth.mfa.trench.serializers import CodeLoginSerializer
3030
from custom_auth.mfa.trench.utils import user_token_generator
3131
from custom_auth.serializers import CustomUserDelete
32+
from integrations.lead_tracking.hubspot.services import (
33+
register_hubspot_tracker,
34+
)
3235
from users.constants import DEFAULT_DELETE_ORPHAN_ORGANISATIONS_VALUE
3336
from users.models import FFAdminUser
3437
from users.serializers import PatchOnboardingSerializer
@@ -125,6 +128,7 @@ def get_throttles(self): # type: ignore[no-untyped-def]
125128

126129
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
127130
response = super().create(request, *args, **kwargs)
131+
register_hubspot_tracker(request, user=self.user)
128132
if settings.COOKIE_AUTH_ENABLED:
129133
authorise_response(self.user, response)
130134
return response # type: ignore[no-any-return]

api/integrations/lead_tracking/hubspot/client.py

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import json
22
import logging
3-
from typing import Any
3+
from typing import TYPE_CHECKING, Any
44

55
import hubspot # type: ignore[import-untyped]
66
import requests
77
from django.conf import settings
8+
from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[import-untyped]
89
from hubspot.crm.companies import ( # type: ignore[import-untyped]
910
PublicObjectSearchRequest,
1011
SimplePublicObjectInput,
@@ -20,7 +21,9 @@
2021
HUBSPOT_PORTAL_ID,
2122
HUBSPOT_ROOT_FORM_URL,
2223
)
23-
from users.models import FFAdminUser
24+
25+
if TYPE_CHECKING:
26+
from users.models import FFAdminUser
2427

2528
logger = logging.getLogger(__name__)
2629

@@ -30,7 +33,7 @@ def __init__(self, client: hubspot.Client = None) -> None:
3033
self.access_token = settings.HUBSPOT_ACCESS_TOKEN
3134
self.client = client or hubspot.Client.create(access_token=self.access_token)
3235

33-
def get_contact(self, user: FFAdminUser) -> None | dict[str, Any]:
36+
def get_contact(self, user: "FFAdminUser") -> None | dict[str, Any]:
3437
public_object_id = BatchReadInputSimplePublicObjectId(
3538
id_property="email",
3639
inputs=[{"id": user.email}],
@@ -54,7 +57,7 @@ def get_contact(self, user: FFAdminUser) -> None | dict[str, Any]:
5457
return results[0] # type: ignore[no-any-return]
5558

5659
def create_lead_form(
57-
self, user: FFAdminUser, hubspot_cookie: str
60+
self, user: "FFAdminUser", hubspot_cookie: str | None = None
5861
) -> dict[str, Any]:
5962
logger.info(
6063
f"Creating Hubspot lead form for user {user.email} with hubspot cookie {hubspot_cookie}"
@@ -69,11 +72,13 @@ def create_lead_form(
6972
{"objectTypeId": "0-1", "name": "lastname", "value": user.last_name},
7073
]
7174

72-
context = {
73-
"hutk": hubspot_cookie,
74-
"pageUri": "www.flagsmith.com",
75-
"pageName": "Homepage",
76-
}
75+
context = {}
76+
if hubspot_cookie:
77+
context = {
78+
"hutk": hubspot_cookie,
79+
"pageUri": "www.flagsmith.com",
80+
"pageName": "Homepage",
81+
}
7782

7883
legal = {
7984
"consent": {
@@ -103,37 +108,20 @@ def create_lead_form(
103108
)
104109
return response.json() # type: ignore[no-any-return]
105110

106-
def create_contact(
107-
self, user: FFAdminUser, hubspot_company_id: str
108-
) -> dict[str, Any]:
109-
properties = {
110-
"email": user.email,
111-
"firstname": user.first_name,
112-
"lastname": user.last_name,
113-
"hs_marketable_status": user.marketing_consent_given,
114-
}
115-
return self._create_contact(properties, hubspot_company_id)
116-
117-
def _create_contact(
118-
self, properties: dict[str, Any], hubspot_company_id: str
119-
) -> dict[str, str]:
120-
response = self.client.crm.contacts.basic_api.create(
121-
simple_public_object_input_for_create=SimplePublicObjectInputForCreate(
122-
properties=properties,
123-
associations=[
124-
{
125-
"types": [
126-
{
127-
"associationCategory": "HUBSPOT_DEFINED",
128-
"associationTypeId": 1,
129-
}
130-
],
131-
"to": {"id": hubspot_company_id},
132-
}
133-
],
111+
def associate_contact_to_company(self, contact_id: str, company_id: str) -> None:
112+
association_spec = [
113+
AssociationSpec(
114+
association_category="HUBSPOT_DEFINED", association_type_id=1
134115
)
116+
]
117+
118+
self.client.crm.associations.v4.basic_api.create(
119+
object_type="contacts",
120+
object_id=contact_id,
121+
to_object_type="companies",
122+
to_object_id=company_id,
123+
association_spec=association_spec,
135124
)
136-
return response.to_dict() # type: ignore[no-any-return]
137125

138126
def create_self_hosted_contact(
139127
self, email: str, first_name: str, last_name: str, hubspot_company_id: str
@@ -144,7 +132,28 @@ def create_self_hosted_contact(
144132
"lastname": last_name,
145133
"api_lead_source": HUBSPOT_API_LEAD_SOURCE_SELF_HOSTED,
146134
}
147-
self._create_contact(properties, hubspot_company_id)
135+
136+
create_params = {
137+
"simple_public_object_input_for_create": SimplePublicObjectInputForCreate(
138+
properties=properties,
139+
)
140+
}
141+
142+
if hubspot_company_id:
143+
create_params["simple_public_object_input_for_create"].associations = [
144+
{
145+
"types": [
146+
{
147+
"associationCategory": "HUBSPOT_DEFINED",
148+
"associationTypeId": 1,
149+
}
150+
],
151+
"to": {"id": hubspot_company_id},
152+
}
153+
]
154+
155+
response = self.client.crm.contacts.basic_api.create(**create_params)
156+
return response.to_dict() # type: ignore[no-any-return]
148157

149158
def get_company_by_domain(self, domain: str) -> dict[str, Any] | None:
150159
"""
@@ -201,11 +210,17 @@ def create_company(
201210
return response.to_dict() # type: ignore[no-any-return]
202211

203212
def update_company(
204-
self, active_subscription: str, hubspot_company_id: str
213+
self,
214+
hubspot_company_id: str,
215+
name: str | None = None,
216+
active_subscription: str | None = None,
205217
) -> dict[str, Any]:
206-
properties = {
207-
"active_subscription": active_subscription,
208-
}
218+
properties = {}
219+
if name is not None:
220+
properties["name"] = name
221+
if active_subscription is not None:
222+
properties["active_subscription"] = active_subscription
223+
209224
simple_public_object_input = SimplePublicObjectInput(properties=properties)
210225

211226
response = self.client.crm.companies.basic_api.update(

api/integrations/lead_tracking/hubspot/lead_tracker.py

Lines changed: 118 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import logging
2+
import time
3+
from typing import Any
24

35
from django.conf import settings
46

@@ -44,88 +46,147 @@ def should_track(user: FFAdminUser) -> bool:
4446

4547
return True
4648

47-
def create_lead(self, user: FFAdminUser, organisation: Organisation = None) -> None: # type: ignore[assignment]
48-
contact_data = self.client.get_contact(user)
49+
def update_company_active_subscription(
50+
self, subscription: Subscription
51+
) -> dict[str, Any] | None:
52+
if not subscription.plan:
53+
return None
4954

50-
if contact_data:
51-
# The user is already present in the system as a lead
52-
# for an existing organisation, so return early.
53-
return
55+
organisation = subscription.organisation
5456

55-
hubspot_id = self.get_or_create_organisation_hubspot_id(user, organisation)
57+
# Check if we're missing the associated hubspot id.
58+
if not getattr(organisation, "hubspot_organisation", None):
59+
return None
60+
61+
response: dict[str, Any] | None = self.client.update_company(
62+
active_subscription=subscription.plan,
63+
hubspot_company_id=organisation.hubspot_organisation.hubspot_id,
64+
)
5665

57-
response = self.client.create_contact(user, hubspot_id)
66+
return response
5867

68+
def create_user_hubspot_contact(self, user: FFAdminUser) -> str | None:
69+
tracker = HubspotTracker.objects.filter(user=user).first()
70+
tracker_cookie = tracker.hubspot_cookie if tracker else None
71+
self.client.create_lead_form(user=user, hubspot_cookie=tracker_cookie)
72+
73+
# Create lead form creates a contact asynchronously in hubspot but does not return the contact id
74+
# We need to get the contact id separately and retry 3 times
75+
contact = self._get_new_contact_with_retry(user, max_retries=3)
76+
if not contact:
77+
# Hubspot creates contact asynchronously
78+
# If not available on the spot, following steps will sync database with Hubspot
79+
logger.error(f"Failed to create contact for user {user.email}")
80+
return None
81+
82+
hubspot_contact_id = contact.get("id")
5983
HubspotLead.objects.update_or_create(
60-
user=user, defaults={"hubspot_id": response["id"]}
84+
user=user, defaults={"hubspot_id": hubspot_contact_id}
6185
)
6286

63-
if tracker := HubspotTracker.objects.filter(user=user).first():
64-
self.client.create_lead_form(
65-
user=user, hubspot_cookie=tracker.hubspot_cookie
66-
)
87+
return hubspot_contact_id
88+
89+
def create_lead(self, user: FFAdminUser, organisation: Organisation) -> None:
90+
hubspot_contact_id = self._get_or_create_user_hubspot_id(user)
91+
if not hubspot_contact_id:
92+
return
93+
hubspot_org_id = self._get_or_create_organisation_hubspot_id(user, organisation)
94+
if not hubspot_org_id:
95+
return
96+
97+
self.client.associate_contact_to_company(
98+
contact_id=hubspot_contact_id,
99+
company_id=hubspot_org_id,
100+
)
67101

68-
def get_or_create_organisation_hubspot_id(
102+
def _get_new_contact_with_retry(
103+
self, user: FFAdminUser, max_retries: int = 3
104+
) -> dict[str, Any] | None:
105+
for retry in range(max_retries + 1):
106+
contact = self.client.get_contact(user)
107+
if contact:
108+
return contact # type: ignore[no-any-return]
109+
time.sleep(0.5 * retry) # 3 retries: 0.5s, 1s, 1.5s
110+
return None
111+
112+
def _get_or_create_user_hubspot_id(self, user: FFAdminUser) -> str | None:
113+
hubspot_lead = HubspotLead.objects.filter(user=user).first()
114+
if hubspot_lead:
115+
hubspot_contact_id: str | None = hubspot_lead.hubspot_id
116+
else:
117+
# Fallback to sync database with Hubspot if contact hubspot_id was not saved
118+
contact_data = self.client.get_contact(user)
119+
if contact_data:
120+
hubspot_contact_id = contact_data["id"]
121+
HubspotLead.objects.update_or_create(
122+
user=user, defaults={"hubspot_id": hubspot_contact_id}
123+
)
124+
else:
125+
logger.error(
126+
f"Fallback creating contact for user {user.email} when associating with organisation"
127+
)
128+
hubspot_contact_id = self.create_user_hubspot_contact(user)
129+
130+
return hubspot_contact_id
131+
132+
def _get_or_create_organisation_hubspot_id(
69133
self,
70134
user: FFAdminUser,
71-
organisation: Organisation = None, # type: ignore[assignment]
72-
) -> str:
135+
organisation: Organisation,
136+
) -> str | None:
73137
"""
74138
Return the Hubspot API's id for an organisation.
75139
"""
76-
if organisation and getattr(organisation, "hubspot_organisation", None):
140+
if getattr(organisation, "hubspot_organisation", None):
77141
return organisation.hubspot_organisation.hubspot_id
78142

79143
if user.email_domain in settings.HUBSPOT_IGNORE_ORGANISATION_DOMAINS:
80-
domain = None
81-
else:
82-
domain = user.email_domain
144+
return None
83145

84-
if organisation:
85-
response = self.client.create_company(
146+
domain = user.email_domain
147+
company_kwargs = {"domain": domain}
148+
company_kwargs["name"] = organisation.name
149+
company_kwargs["organisation_id"] = organisation.id
150+
company_kwargs["active_subscription"] = organisation.subscription.plan
151+
152+
# As Hubspot creates/associates companies based on contact domain
153+
# we need to get the hubspot id when this user creates the company for the first time
154+
# and update the company name
155+
company = self._get_or_create_hubspot_company(**company_kwargs)
156+
org_hubspot_id: str = company["id"]
157+
158+
properties = company.get("properties", {})
159+
existing_name = properties.get("name")
160+
if existing_name != organisation.name:
161+
self.client.update_company(
86162
name=organisation.name,
87-
active_subscription=organisation.subscription.plan,
88-
organisation_id=organisation.id,
89-
domain=domain,
163+
hubspot_company_id=org_hubspot_id,
90164
)
91165

92-
# Store the organisation data in the database since we are
93-
# unable to look them up via a unique identifier.
94-
HubspotOrganisation.objects.create(
95-
organisation=organisation,
96-
hubspot_id=response["id"],
97-
)
98-
else:
99-
response = self._get_or_create_company_by_domain(domain) # type: ignore[arg-type]
100-
101-
return response["id"] # type: ignore[no-any-return]
102-
103-
def update_company_active_subscription(
104-
self, subscription: Subscription
105-
) -> dict | None: # type: ignore[type-arg]
106-
if not subscription.plan:
107-
return # type: ignore[return-value]
108-
109-
organisation = subscription.organisation
110-
111-
# Check if we're missing the associated hubspot id.
112-
if not getattr(organisation, "hubspot_organisation", None):
113-
return # type: ignore[return-value]
114-
115-
response = self.client.update_company(
116-
active_subscription=subscription.plan,
117-
hubspot_company_id=organisation.hubspot_organisation.hubspot_id,
166+
# Store the organisation data in the database since we are
167+
# unable to look them up via a unique identifier.
168+
HubspotOrganisation.objects.create(
169+
organisation=organisation,
170+
hubspot_id=org_hubspot_id,
118171
)
119172

120-
return response # type: ignore[no-any-return]
173+
return org_hubspot_id
121174

122-
def _get_or_create_company_by_domain(self, domain: str) -> dict: # type: ignore[type-arg]
175+
def _get_or_create_hubspot_company(
176+
self,
177+
domain: str,
178+
organisation_id: int,
179+
name: str,
180+
active_subscription: str | None = None,
181+
) -> dict[str, Any]:
123182
company = self.client.get_company_by_domain(domain)
124183
if not company:
125-
# Since we don't know the company's name, we pass the domain as
126-
# both the name and the domain. This can then be manually
127-
# updated in Hubspot if needed.
128-
company = self.client.create_company(name=domain, domain=domain)
184+
company = self.client.create_company(
185+
name=name,
186+
domain=domain,
187+
organisation_id=organisation_id,
188+
active_subscription=active_subscription,
189+
)
129190

130191
return company # type: ignore[no-any-return]
131192

0 commit comments

Comments
 (0)