Skip to content

Commit

Permalink
chore(saas/hubspot): create contacts with default domain (#3830)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewelwell authored Apr 24, 2024
1 parent 3ac72a2 commit ac674ed
Show file tree
Hide file tree
Showing 8 changed files with 369 additions and 25 deletions.
5 changes: 5 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,11 @@
"HUBSPOT_IGNORE_ORGANISATION_DOMAINS", []
)

# Number of minutes to wait for a user that has signed up to
# join or create an organisation before creating a lead in
# hubspot without a Flagsmith organisation.
CREATE_HUBSPOT_LEAD_WITHOUT_ORGANISATION_DELAY_MINUTES = 30

# List of plan ids that support seat upgrades
AUTO_SEAT_UPGRADE_PLANS = env.list("AUTO_SEAT_UPGRADE_PLANS", default=[])

Expand Down
46 changes: 41 additions & 5 deletions api/integrations/lead_tracking/hubspot/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import hubspot
from django.conf import settings
from hubspot.crm.companies import (
PublicObjectSearchRequest,
SimplePublicObjectInput,
SimplePublicObjectInputForCreate,
)
Expand All @@ -14,9 +15,9 @@


class HubspotClient:
def __init__(self) -> None:
def __init__(self, client: hubspot.Client = None) -> None:
access_token = settings.HUBSPOT_ACCESS_TOKEN
self.client = hubspot.Client.create(access_token=access_token)
self.client = client or hubspot.Client.create(access_token=access_token)

def get_contact(self, user: FFAdminUser) -> None | dict:
public_object_id = BatchReadInputSimplePublicObjectId(
Expand Down Expand Up @@ -67,20 +68,55 @@ 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:
"""
Domain should be unique in Hubspot by design, so we should only ever have
0 or 1 results.
"""
public_object_search_request = PublicObjectSearchRequest(
filter_groups=[
{
"filters": [
{"value": domain, "propertyName": "domain", "operator": "EQ"}
]
}
]
)

response = self.client.crm.companies.search_api.do_search(
public_object_search_request=public_object_search_request,
)

results = response.to_dict()["results"]
if not results:
return None

if len(results) > 1:
logger.error("Multiple companies exist in Hubspot for domain %s.", domain)

return results[0]

def create_company(
self,
name: str,
active_subscription: str,
organisation_id: int,
domain: str | None,
active_subscription: str = None,
organisation_id: int = None,
domain: str | None = None,
) -> dict:
properties = {
"name": name,
"active_subscription": active_subscription,
"orgid": str(organisation_id),
}

if domain:
properties["domain"] = domain
if active_subscription:
properties["active_subscription"] = active_subscription

# hubspot doesn't allow null values for numeric fields, so we
# set this to -1 for auto generated organisations.
properties["orgid"] = organisation_id or -1

simple_public_object_input_for_create = SimplePublicObjectInputForCreate(
properties=properties,
Expand Down
43 changes: 27 additions & 16 deletions api/integrations/lead_tracking/hubspot/lead_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,46 +50,50 @@ def should_track(user: FFAdminUser) -> bool:

return True

def create_lead(self, user: FFAdminUser, organisation: Organisation) -> None:
def create_lead(self, user: FFAdminUser, organisation: Organisation = None) -> 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, user)
hubspot_id = self.get_or_create_organisation_hubspot_id(user, organisation)

response = self.client.create_contact(user, hubspot_id)

HubspotLead.objects.create(user=user, hubspot_id=response["id"])

def get_or_create_organisation_hubspot_id(
self, organisation: Organisation, user: FFAdminUser
self, user: FFAdminUser, organisation: Organisation = None
) -> str:
"""
Return the Hubspot API's id for an organisation.
"""
if getattr(organisation, "hubspot_organisation", None):
if organisation and getattr(organisation, "hubspot_organisation", None):
return organisation.hubspot_organisation.hubspot_id

if user.email_domain in settings.HUBSPOT_IGNORE_ORGANISATION_DOMAINS:
domain = None
else:
domain = user.email_domain
response = self.client.create_company(
name=organisation.name,
active_subscription=organisation.subscription.plan,
organisation_id=organisation.id,
domain=domain,
)

# 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"],
)
if organisation:
response = self.client.create_company(
name=organisation.name,
active_subscription=organisation.subscription.plan,
organisation_id=organisation.id,
domain=domain,
)

# 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"],
)
else:
response = self._get_or_create_company_by_domain(domain)

return response["id"]

Expand All @@ -112,5 +116,12 @@ def update_company_active_subscription(

return response

def _get_or_create_company_by_domain(self, domain: str) -> dict:
company = self.client.get_company_by_domain(domain)
if not company:
company = self.client.create_company(name=domain)

return company

def _get_client(self) -> HubspotClient:
return HubspotClient()
34 changes: 30 additions & 4 deletions api/integrations/lead_tracking/hubspot/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


@register_task_handler()
def track_hubspot_lead(user_id: int, organisation_id: int) -> None:
def track_hubspot_lead(user_id: int, organisation_id: int = None) -> None:
assert settings.ENABLE_HUBSPOT_LEAD_TRACKING

# Avoid circular imports.
Expand All @@ -18,10 +18,15 @@ def track_hubspot_lead(user_id: int, organisation_id: int) -> None:
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)

create_lead_kwargs = {"user": user}
if organisation_id:
create_lead_kwargs["organisation"] = Organisation.objects.get(
id=organisation_id
)

hubspot_lead_tracker.create_lead(**create_lead_kwargs)


@register_task_handler()
Expand All @@ -35,3 +40,24 @@ def update_hubspot_active_subscription(subscription_id: int) -> None:
subscription = Subscription.objects.get(id=subscription_id)
hubspot_lead_tracker = HubspotLeadTracker()
hubspot_lead_tracker.update_company_active_subscription(subscription)


@register_task_handler()
def track_hubspot_lead_without_organisation(user_id: int) -> None:
"""
The Hubspot logic relies on users joining or creating an organisation
to be tracked. This should cover most use cases, but for users that
sign up but don't join or create an organisation we still want to be
able to track them.
"""

from users.models import FFAdminUser

user = FFAdminUser.objects.get(id=user_id)
if hasattr(user, "hubspot_lead"):
# Since this task is designed to be delayed, there's a chance
# that the user will have joined an organisation and thus been
# tracked in hubspot already. If so, do nothing.
return

track_hubspot_lead(user.id)
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from datetime import datetime

from dateutil.tz import tzlocal


class FakeHubspotResponse:
"""
Dummy class to replicate the to_dict() method of the Hubspot response classes.
"""

def __init__(self, data: dict) -> None:
self.data = data

def to_dict(self) -> dict:
return self.data


def generate_get_company_by_domain_response(
name: str, domain: str
) -> FakeHubspotResponse:
"""
Generate a sample response given by the Hubspot API when searching for a company by domain.
This response was retrieved from the API directly, and then modified to allow us to set the
certain properties dynamically.
"""

return FakeHubspotResponse(
data={
"paging": None,
"results": [
{
"archived": False,
"archived_at": None,
"created_at": datetime(
2024, 1, 25, 21, 48, 28, 655000, tzinfo=tzlocal()
),
"id": "9765318341",
"properties": {
"createdate": "2024-01-25T21:48:28.655Z",
"domain": domain,
"hs_lastmodifieddate": "2024-04-23T15:54:13.336Z",
"hs_object_id": "9765318341",
"name": name,
},
"properties_with_history": None,
"updated_at": datetime(
2024, 4, 23, 15, 54, 13, 336000, tzinfo=tzlocal()
),
}
],
"total": 1,
}
)


def generate_get_company_by_domain_response_no_results() -> FakeHubspotResponse:
"""
Generate a sample response given by the Hubspot API when searching for a company by domain
but no results are returned.
This response was retrieved from the API directly and hard coded here for simplicity.
"""

return FakeHubspotResponse(
data={
"paging": None,
"results": [],
"total": 0,
}
)


def generate_create_company_response(
name: str, domain: str | None = None, organisation_id: int = -1
) -> FakeHubspotResponse:
"""
Generate a sample response given by the Hubspot API when creating a company.
This response was retrieved from the API directly, and then modified to allow us to set the
properties dynamically.
"""

return FakeHubspotResponse(
data={
"archived": False,
"archived_at": None,
"created_at": datetime(2024, 4, 23, 17, 36, 50, 158000, tzinfo=tzlocal()),
"id": "11349198823",
"properties": {
"active_subscription": None,
"createdate": "2024-04-23T17:36:50.158Z",
"domain": domain,
"hs_lastmodifieddate": "2024-04-23T17:36:50.158Z",
"hs_object_id": "11349198823",
"hs_object_source": "INTEGRATION",
"hs_object_source_id": "2902325",
"hs_object_source_label": "INTEGRATION",
"name": name,
"orgid": organisation_id,
"website": domain,
},
"properties_with_history": None,
"updated_at": datetime(2024, 4, 23, 17, 36, 50, 158000, tzinfo=tzlocal()),
}
)
Loading

0 comments on commit ac674ed

Please sign in to comment.