Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(saas/hubspot): create contacts with default domain #3830

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved

simple_public_object_input_for_create = SimplePublicObjectInputForCreate(
properties=properties,
Expand Down
46 changes: 30 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,15 @@ def update_company_active_subscription(

return response

def _get_or_create_company_by_domain(self, domain: str) -> dict:
# try to find the organisation via it's domain
company = self.client.get_company_by_domain(domain)
if not company:
# otherwise create a new organisation with the domain
# as the name.
company = self.client.create_company(name=domain)
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved

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 DummyHubspotResponse:
"""
Dummy class to replicate the to_dict() method of the Hubspot response classes.
"""
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved

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
) -> DummyHubspotResponse:
"""
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 DummyHubspotResponse(
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",
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
"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() -> DummyHubspotResponse:
"""
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 DummyHubspotResponse(
data={
"paging": None,
"results": [],
"total": 0,
}
)


def generate_create_company_response(
name: str, domain: str = None, organisation_id: int = None
matthewelwell marked this conversation as resolved.
Show resolved Hide resolved
) -> DummyHubspotResponse:
"""
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 DummyHubspotResponse(
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