Skip to content

Commit

Permalink
feat: Track leads to Hubspot (#3473)
Browse files Browse the repository at this point in the history
Co-authored-by: Matthew Elwell <matthew.elwell@flagsmith.com>
  • Loading branch information
zachaysan and matthewelwell authored Feb 28, 2024
1 parent 87cfcd9 commit 02c59d2
Show file tree
Hide file tree
Showing 9 changed files with 472 additions and 6 deletions.
7 changes: 7 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[])

Expand Down
77 changes: 77 additions & 0 deletions api/integrations/lead_tracking/hubspot/client.py
Original file line number Diff line number Diff line change
@@ -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()
78 changes: 78 additions & 0 deletions api/integrations/lead_tracking/hubspot/lead_tracker.py
Original file line number Diff line number Diff line change
@@ -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()
24 changes: 24 additions & 0 deletions api/integrations/lead_tracking/hubspot/tasks.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions api/organisations/migrations/0052_create_hubspot_organisation.py
Original file line number Diff line number Diff line change
@@ -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')),
],
),
]
24 changes: 23 additions & 1 deletion api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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)
29 changes: 24 additions & 5 deletions api/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 02c59d2

Please sign in to comment.