mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
feat(cdp): add linkedin integration (#26282)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
BIN
frontend/public/services/linkedin.png
Normal file
BIN
frontend/public/services/linkedin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
@@ -69,6 +69,8 @@ import {
|
||||
HogFunctionTypeType,
|
||||
InsightModel,
|
||||
IntegrationType,
|
||||
LinkedInAdsAccountType,
|
||||
LinkedInAdsConversionRuleType,
|
||||
ListOrganizationMembersParams,
|
||||
LogEntry,
|
||||
LogEntryRequestParams,
|
||||
@@ -820,6 +822,21 @@ class ApiRequest {
|
||||
.withQueryString({ customerId })
|
||||
}
|
||||
|
||||
public integrationLinkedInAdsAccounts(id: IntegrationType['id'], teamId?: TeamType['id']): ApiRequest {
|
||||
return this.integrations(teamId).addPathComponent(id).addPathComponent('linkedin_ads_accounts')
|
||||
}
|
||||
|
||||
public integrationLinkedInAdsConversionRules(
|
||||
id: IntegrationType['id'],
|
||||
accountId: string,
|
||||
teamId?: TeamType['id']
|
||||
): ApiRequest {
|
||||
return this.integrations(teamId)
|
||||
.addPathComponent(id)
|
||||
.addPathComponent('linkedin_ads_conversion_rules')
|
||||
.withQueryString({ accountId })
|
||||
}
|
||||
|
||||
public media(teamId?: TeamType['id']): ApiRequest {
|
||||
return this.projectsDetail(teamId).addPathComponent('uploaded_media')
|
||||
}
|
||||
@@ -2562,6 +2579,15 @@ const api = {
|
||||
): Promise<{ conversionActions: GoogleAdsConversionActionType[] }> {
|
||||
return await new ApiRequest().integrationGoogleAdsConversionActions(id, customerId).get()
|
||||
},
|
||||
async linkedInAdsAccounts(id: IntegrationType['id']): Promise<{ adAccounts: LinkedInAdsAccountType[] }> {
|
||||
return await new ApiRequest().integrationLinkedInAdsAccounts(id).get()
|
||||
},
|
||||
async linkedInAdsConversionRules(
|
||||
id: IntegrationType['id'],
|
||||
accountId: string
|
||||
): Promise<{ conversionRules: LinkedInAdsConversionRuleType[] }> {
|
||||
return await new ApiRequest().integrationLinkedInAdsConversionRules(id, accountId).get()
|
||||
},
|
||||
},
|
||||
|
||||
resourcePermissions: {
|
||||
|
||||
151
frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx
Normal file
151
frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { LemonInputSelect, LemonInputSelectOption } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { IntegrationType, LinkedInAdsAccountType, LinkedInAdsConversionRuleType } from '~/types'
|
||||
|
||||
import { linkedInAdsIntegrationLogic } from './linkedInAdsIntegrationLogic'
|
||||
|
||||
const getLinkedInAdsAccountOptions = (
|
||||
linkedInAdsAccounts?: LinkedInAdsAccountType[] | null
|
||||
): LemonInputSelectOption[] | null => {
|
||||
return linkedInAdsAccounts
|
||||
? linkedInAdsAccounts.map((account) => ({
|
||||
key: account.id.toString(),
|
||||
labelComponent: (
|
||||
<span className="flex items-center">
|
||||
{account.name} ({account.id.toString()})
|
||||
</span>
|
||||
),
|
||||
label: `${account.name}`,
|
||||
}))
|
||||
: null
|
||||
}
|
||||
|
||||
const getLinkedInAdsConversionRuleOptions = (
|
||||
linkedInAdsConversionRules?: LinkedInAdsConversionRuleType[] | null
|
||||
): LemonInputSelectOption[] | null => {
|
||||
return linkedInAdsConversionRules
|
||||
? linkedInAdsConversionRules.map(({ id, name }) => ({
|
||||
key: id.toString(),
|
||||
labelComponent: (
|
||||
<span className="flex items-center">
|
||||
{name} ({id})
|
||||
</span>
|
||||
),
|
||||
label: `${name} (${id})`,
|
||||
}))
|
||||
: null
|
||||
}
|
||||
|
||||
export type LinkedInAdsPickerProps = {
|
||||
integration: IntegrationType
|
||||
value?: string
|
||||
onChange?: (value: string | null) => void
|
||||
disabled?: boolean
|
||||
requiresFieldValue?: string
|
||||
}
|
||||
|
||||
export function LinkedInAdsConversionRulePicker({
|
||||
onChange,
|
||||
value,
|
||||
requiresFieldValue,
|
||||
integration,
|
||||
disabled,
|
||||
}: LinkedInAdsPickerProps): JSX.Element {
|
||||
const { linkedInAdsConversionRules, linkedInAdsConversionRulesLoading } = useValues(
|
||||
linkedInAdsIntegrationLogic({ id: integration.id })
|
||||
)
|
||||
const { loadLinkedInAdsConversionRules } = useActions(linkedInAdsIntegrationLogic({ id: integration.id }))
|
||||
|
||||
const linkedInAdsConversionRuleOptions = useMemo(
|
||||
() => getLinkedInAdsConversionRuleOptions(linkedInAdsConversionRules),
|
||||
[linkedInAdsConversionRules]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresFieldValue) {
|
||||
loadLinkedInAdsConversionRules(requiresFieldValue)
|
||||
}
|
||||
}, [loadLinkedInAdsConversionRules, requiresFieldValue])
|
||||
|
||||
return (
|
||||
<>
|
||||
<LemonInputSelect
|
||||
onChange={(val) => onChange?.(val[0] ?? null)}
|
||||
value={value ? [value] : []}
|
||||
onFocus={() =>
|
||||
!linkedInAdsConversionRules &&
|
||||
!linkedInAdsConversionRulesLoading &&
|
||||
requiresFieldValue &&
|
||||
loadLinkedInAdsConversionRules(requiresFieldValue)
|
||||
}
|
||||
disabled={disabled}
|
||||
mode="single"
|
||||
data-attr="select-linkedin-ads-conversion-action"
|
||||
placeholder="Select a Conversion Action..."
|
||||
options={
|
||||
linkedInAdsConversionRuleOptions ??
|
||||
(value
|
||||
? [
|
||||
{
|
||||
key: value,
|
||||
label: value,
|
||||
},
|
||||
]
|
||||
: [])
|
||||
}
|
||||
loading={linkedInAdsConversionRulesLoading}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinkedInAdsAccountIdPicker({
|
||||
onChange,
|
||||
value,
|
||||
integration,
|
||||
disabled,
|
||||
}: LinkedInAdsPickerProps): JSX.Element {
|
||||
const { linkedInAdsAccounts, linkedInAdsAccountsLoading } = useValues(
|
||||
linkedInAdsIntegrationLogic({ id: integration.id })
|
||||
)
|
||||
const { loadLinkedInAdsAccounts } = useActions(linkedInAdsIntegrationLogic({ id: integration.id }))
|
||||
|
||||
const linkedInAdsAccountOptions = useMemo(
|
||||
() => getLinkedInAdsAccountOptions(linkedInAdsAccounts),
|
||||
[linkedInAdsAccounts]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
loadLinkedInAdsAccounts()
|
||||
}
|
||||
}, [loadLinkedInAdsAccounts])
|
||||
|
||||
return (
|
||||
<>
|
||||
<LemonInputSelect
|
||||
onChange={(val) => onChange?.(val[0] ?? null)}
|
||||
value={value ? [value] : []}
|
||||
onFocus={() => !linkedInAdsAccounts && !linkedInAdsAccountsLoading && loadLinkedInAdsAccounts()}
|
||||
disabled={disabled}
|
||||
mode="single"
|
||||
data-attr="select-linkedin-ads-customer-id-channel"
|
||||
placeholder="Select a Account ID..."
|
||||
options={
|
||||
linkedInAdsAccountOptions ??
|
||||
(value
|
||||
? [
|
||||
{
|
||||
key: value,
|
||||
label: value.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3'),
|
||||
},
|
||||
]
|
||||
: [])
|
||||
}
|
||||
loading={linkedInAdsAccountsLoading}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import IconGoogleAds from 'public/services/google-ads.png'
|
||||
import IconGoogleCloud from 'public/services/google-cloud.png'
|
||||
import IconGoogleCloudStorage from 'public/services/google-cloud-storage.png'
|
||||
import IconHubspot from 'public/services/hubspot.png'
|
||||
import IconLinkedIn from 'public/services/linkedin.png'
|
||||
import IconSalesforce from 'public/services/salesforce.png'
|
||||
import IconSlack from 'public/services/slack.png'
|
||||
import IconSnapchat from 'public/services/snapchat.png'
|
||||
@@ -26,6 +27,7 @@ const ICONS: Record<IntegrationKind, any> = {
|
||||
'google-cloud-storage': IconGoogleCloudStorage,
|
||||
'google-ads': IconGoogleAds,
|
||||
snapchat: IconSnapchat,
|
||||
'linkedin-ads': IconLinkedIn,
|
||||
}
|
||||
|
||||
export const integrationsLogic = kea<integrationsLogicType>([
|
||||
|
||||
37
frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts
Normal file
37
frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { actions, kea, key, path, props } from 'kea'
|
||||
import { loaders } from 'kea-loaders'
|
||||
import api from 'lib/api'
|
||||
|
||||
import { LinkedInAdsAccountType, LinkedInAdsConversionRuleType } from '~/types'
|
||||
|
||||
import type { linkedInAdsIntegrationLogicType } from './linkedInAdsIntegrationLogicType'
|
||||
|
||||
export const linkedInAdsIntegrationLogic = kea<linkedInAdsIntegrationLogicType>([
|
||||
props({} as { id: number }),
|
||||
key((props) => props.id),
|
||||
path((key) => ['lib', 'integrations', 'linkedInAdsIntegrationLogic', key]),
|
||||
actions({
|
||||
loadLinkedInAdsConversionRules: (accountId: string) => accountId,
|
||||
loadLinkedInAdsAccounts: true,
|
||||
}),
|
||||
loaders(({ props }) => ({
|
||||
linkedInAdsConversionRules: [
|
||||
null as LinkedInAdsConversionRuleType[] | null,
|
||||
{
|
||||
loadLinkedInAdsConversionRules: async (customerId: string) => {
|
||||
const res = await api.integrations.linkedInAdsConversionRules(props.id, customerId)
|
||||
return res.conversionRules
|
||||
},
|
||||
},
|
||||
],
|
||||
linkedInAdsAccounts: [
|
||||
null as LinkedInAdsAccountType[] | null,
|
||||
{
|
||||
loadLinkedInAdsAccounts: async () => {
|
||||
const res = await api.integrations.linkedInAdsAccounts(props.id)
|
||||
return res.adAccounts
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
])
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
GoogleAdsCustomerIdPicker,
|
||||
} from 'lib/integrations/GoogleAdsIntegrationHelpers'
|
||||
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
|
||||
import {
|
||||
LinkedInAdsAccountIdPicker,
|
||||
LinkedInAdsConversionRulePicker,
|
||||
} from 'lib/integrations/LinkedInIntegrationHelpers'
|
||||
import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers'
|
||||
|
||||
import { HogFunctionInputSchemaType } from '~/types'
|
||||
@@ -99,6 +103,25 @@ export function HogFunctionInputIntegrationField({
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (schema.integration_field === 'linkedin_ads_conversion_rule_id' && requiresFieldValue) {
|
||||
return (
|
||||
<LinkedInAdsConversionRulePicker
|
||||
value={value}
|
||||
requiresFieldValue={requiresFieldValue}
|
||||
onChange={(x) => onChange?.(x?.split('|')[0])}
|
||||
integration={integration}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (schema.integration_field === 'linkedin_ads_account_id') {
|
||||
return (
|
||||
<LinkedInAdsAccountIdPicker
|
||||
value={value}
|
||||
onChange={(x) => onChange?.(x?.split('|')[0])}
|
||||
integration={integration}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="text-danger">
|
||||
<p>Unsupported integration type: {schema.integration}</p>
|
||||
|
||||
@@ -3748,6 +3748,7 @@ export type IntegrationKind =
|
||||
| 'google-pubsub'
|
||||
| 'google-cloud-storage'
|
||||
| 'google-ads'
|
||||
| 'linkedin-ads'
|
||||
| 'snapchat'
|
||||
|
||||
export interface IntegrationType {
|
||||
@@ -4844,6 +4845,17 @@ export type GoogleAdsConversionActionType = {
|
||||
resourceName: string
|
||||
}
|
||||
|
||||
export type LinkedInAdsConversionRuleType = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type LinkedInAdsAccountType = {
|
||||
id: number
|
||||
name: string
|
||||
campaigns: string
|
||||
}
|
||||
|
||||
export type DataColorThemeModel = {
|
||||
id: number
|
||||
name: string
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"disabledLabels": [
|
||||
"no-greptile"
|
||||
]
|
||||
"disabledLabels": ["no-greptile"]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ from posthog.models.integration import (
|
||||
SlackIntegration,
|
||||
GoogleCloudIntegration,
|
||||
GoogleAdsIntegration,
|
||||
LinkedInAdsIntegration,
|
||||
)
|
||||
|
||||
|
||||
@@ -138,3 +139,36 @@ class IntegrationViewSet(
|
||||
response_data = {"accessibleAccounts": google_ads.list_google_ads_accessible_accounts()}
|
||||
cache.set(key, response_data, 60)
|
||||
return Response(response_data)
|
||||
|
||||
@action(methods=["GET"], detail=True, url_path="linkedin_ads_conversion_rules")
|
||||
def linkedin_ad_conversion_rules(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
instance = self.get_object()
|
||||
linkedin_ads = LinkedInAdsIntegration(instance)
|
||||
account_id = request.query_params.get("accountId")
|
||||
|
||||
response = linkedin_ads.list_linkedin_ads_conversion_rules(account_id)
|
||||
conversion_rules = [
|
||||
{
|
||||
"id": conversionRule["id"],
|
||||
"name": conversionRule["name"],
|
||||
}
|
||||
for conversionRule in (response if isinstance(response, list) else [])
|
||||
]
|
||||
|
||||
return Response({"conversionRules": conversion_rules})
|
||||
|
||||
@action(methods=["GET"], detail=True, url_path="linkedin_ads_accounts")
|
||||
def linkedin_ad_accounts(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||
instance = self.get_object()
|
||||
linkedin_ads = LinkedInAdsIntegration(instance)
|
||||
|
||||
accounts = [
|
||||
{
|
||||
"id": account["id"],
|
||||
"name": account["name"],
|
||||
"reference": account["reference"],
|
||||
}
|
||||
for account in linkedin_ads.list_linkedin_ads_accounts()["elements"]
|
||||
]
|
||||
|
||||
return Response({"adAccounts": accounts})
|
||||
|
||||
@@ -36,6 +36,7 @@ from .google_ads.template_google_ads import template as google_ads
|
||||
from .attio.template_attio import template as attio
|
||||
from .mailchimp.template_mailchimp import template as mailchimp
|
||||
from .microsoft_teams.template_microsoft_teams import template as microsoft_teams
|
||||
from .linkedin_ads.template_linkedin_ads import template as linkedin_ads
|
||||
from .klaviyo.template_klaviyo import template_user as klaviyo_user, template_event as klaviyo_event
|
||||
from .google_cloud_storage.template_google_cloud_storage import (
|
||||
template as google_cloud_storage,
|
||||
@@ -84,6 +85,7 @@ HOG_FUNCTION_TEMPLATES = [
|
||||
klaviyo_event,
|
||||
klaviyo_user,
|
||||
knock,
|
||||
linkedin_ads,
|
||||
loops,
|
||||
loops_send_event,
|
||||
mailchimp,
|
||||
|
||||
155
posthog/cdp/templates/linkedin_ads/template_linkedin_ads.py
Normal file
155
posthog/cdp/templates/linkedin_ads/template_linkedin_ads.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate
|
||||
|
||||
template: HogFunctionTemplate = HogFunctionTemplate(
|
||||
status="alpha",
|
||||
type="destination",
|
||||
id="template-linkedin-ads",
|
||||
name="LinkedIn Ads Conversions",
|
||||
description="Send conversion events to LinkedIn Ads",
|
||||
icon_url="/static/services/linkedin.png",
|
||||
category=["Advertisement"],
|
||||
hog="""
|
||||
let body := {
|
||||
'conversion': f'urn:lla:llaPartnerConversion:{inputs.conversionRuleId}',
|
||||
'conversionHappenedAt': inputs.conversionDateTime,
|
||||
'conversionValue': {},
|
||||
'user': {
|
||||
'userIds': [],
|
||||
'userInfo': {}
|
||||
},
|
||||
'eventId' : inputs.eventId
|
||||
}
|
||||
|
||||
if (not empty(inputs.currencyCode)) {
|
||||
body.conversionValue.currencyCode := inputs.currencyCode
|
||||
}
|
||||
if (not empty(inputs.conversionValue)) {
|
||||
body.conversionValue.amount := inputs.conversionValue
|
||||
}
|
||||
|
||||
for (let key, value in inputs.userInfo) {
|
||||
if (not empty(value)) {
|
||||
body.user.userInfo[key] := value
|
||||
}
|
||||
}
|
||||
|
||||
for (let key, value in inputs.userIds) {
|
||||
if (not empty(value)) {
|
||||
body.user.userIds := arrayPushBack(body.user.userIds, {'idType': key, 'idValue': value})
|
||||
}
|
||||
}
|
||||
|
||||
let res := fetch('https://api.linkedin.com/rest/conversionEvents', {
|
||||
'method': 'POST',
|
||||
'headers': {
|
||||
'Authorization': f'Bearer {inputs.oauth.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
'LinkedIn-Version': '202409'
|
||||
},
|
||||
'body': body
|
||||
})
|
||||
|
||||
if (res.status >= 400) {
|
||||
throw Error(f'Error from api.linkedin.com (status {res.status}): {res.body}')
|
||||
}
|
||||
""".strip(),
|
||||
inputs_schema=[
|
||||
{
|
||||
"key": "oauth",
|
||||
"type": "integration",
|
||||
"integration": "linkedin-ads",
|
||||
"label": "LinkedIn Ads account",
|
||||
"secret": False,
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"key": "accountId",
|
||||
"type": "integration_field",
|
||||
"integration_key": "oauth",
|
||||
"integration_field": "linkedin_ads_account_id",
|
||||
"label": "Account ID",
|
||||
"description": "ID of your LinkedIn Ads Account. This should be 9-digits and in XXXXXXXXX format.",
|
||||
"secret": False,
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"key": "conversionRuleId",
|
||||
"type": "integration_field",
|
||||
"integration_key": "oauth",
|
||||
"integration_field": "linkedin_ads_conversion_rule_id",
|
||||
"requires_field": "accountId",
|
||||
"label": "Conversion rule",
|
||||
"description": "The Conversion rule associated with this conversion.",
|
||||
"secret": False,
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"key": "conversionDateTime",
|
||||
"type": "string",
|
||||
"label": "Conversion Date Time",
|
||||
"description": "The timestamp at which the conversion occurred in milliseconds. Must be after the click time.",
|
||||
"default": "{toUnixTimestampMilli(event.timestamp)}",
|
||||
"secret": False,
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"key": "conversionValue",
|
||||
"type": "string",
|
||||
"label": "Conversion value",
|
||||
"description": "The value of the conversion for the advertiser in decimal string. (e.g. “100.05”).",
|
||||
"default": "",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
},
|
||||
{
|
||||
"key": "currencyCode",
|
||||
"type": "string",
|
||||
"label": "Currency code",
|
||||
"description": "Currency associated with the conversion value. This is the ISO 4217 3-character currency code. For example: USD, EUR.",
|
||||
"default": "",
|
||||
"secret": False,
|
||||
"required": False,
|
||||
},
|
||||
{
|
||||
"key": "eventId",
|
||||
"type": "string",
|
||||
"label": "Event ID",
|
||||
"description": "ID of the event that triggered the conversion.",
|
||||
"default": "{event.uuid}",
|
||||
"secret": False,
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"key": "userIds",
|
||||
"type": "dictionary",
|
||||
"label": "User ids",
|
||||
"description": "A map that contains user ids. See this page for options: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-03&tabs=curl#idtype",
|
||||
"default": {
|
||||
"SHA256_EMAIL": "{sha256Hex(person.properties.email)}",
|
||||
"LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID": "{person.properties.li_fat_id ?? person.properties.$initial_li_fat_id}",
|
||||
},
|
||||
"secret": False,
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"key": "userInfo",
|
||||
"type": "dictionary",
|
||||
"label": "User information",
|
||||
"description": "A map that contains user information data. See this page for options: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-03&tabs=curl#userinfo",
|
||||
"default": {
|
||||
"firstName": "{person.properties.first_name}",
|
||||
"lastName": "{person.properties.last_name}",
|
||||
"title": "{person.properties.title}",
|
||||
"companyName": "{person.properties.company}",
|
||||
"countryCode": "{person.properties.$geoip_country_code}",
|
||||
},
|
||||
"secret": False,
|
||||
"required": True,
|
||||
},
|
||||
],
|
||||
filters={
|
||||
"events": [],
|
||||
"actions": [],
|
||||
"filter_test_accounts": True,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
from inline_snapshot import snapshot
|
||||
from posthog.cdp.templates.helpers import BaseHogFunctionTemplateTest
|
||||
from posthog.cdp.templates.linkedin_ads.template_linkedin_ads import (
|
||||
template as template_linkedin_ads,
|
||||
)
|
||||
|
||||
|
||||
class TestTemplateLinkedInAds(BaseHogFunctionTemplateTest):
|
||||
template = template_linkedin_ads
|
||||
|
||||
def _inputs(self, **kwargs):
|
||||
inputs = {
|
||||
"oauth": {
|
||||
"access_token": "oauth-1234",
|
||||
},
|
||||
"accountId": "account-12345",
|
||||
"conversionRuleId": "conversion-rule-12345",
|
||||
"conversionDateTime": 1737464596570,
|
||||
"conversionValue": "100",
|
||||
"currencyCode": "USD",
|
||||
"eventId": "event-12345",
|
||||
"userIds": {
|
||||
"SHA256_EMAIL": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98",
|
||||
"LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID": "abc",
|
||||
},
|
||||
"userInfo": {"lastName": "AI", "firstName": "Max", "companyName": "PostHog", "countryCode": "US"},
|
||||
}
|
||||
inputs.update(kwargs)
|
||||
return inputs
|
||||
|
||||
def test_function_works(self):
|
||||
self.run_function(self._inputs())
|
||||
assert self.get_mock_fetch_calls()[0] == snapshot(
|
||||
(
|
||||
"https://api.linkedin.com/rest/conversionEvents",
|
||||
{
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Authorization": "Bearer oauth-1234",
|
||||
"Content-Type": "application/json",
|
||||
"LinkedIn-Version": "202409",
|
||||
},
|
||||
"body": {
|
||||
"conversion": "urn:lla:llaPartnerConversion:conversion-rule-12345",
|
||||
"conversionHappenedAt": 1737464596570,
|
||||
"conversionValue": {"currencyCode": "USD", "amount": "100"},
|
||||
"user": {
|
||||
"userIds": [
|
||||
{
|
||||
"idType": "SHA256_EMAIL",
|
||||
"idValue": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98",
|
||||
},
|
||||
{"idType": "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID", "idValue": "abc"},
|
||||
],
|
||||
"userInfo": {
|
||||
"lastName": "AI",
|
||||
"firstName": "Max",
|
||||
"companyName": "PostHog",
|
||||
"countryCode": "US",
|
||||
},
|
||||
},
|
||||
"eventId": "event-12345",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
29
posthog/migrations/0558_alter_integration_kind.py
Normal file
29
posthog/migrations/0558_alter_integration_kind.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 4.2.18 on 2025-01-31 14:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("posthog", "0557_add_tags_to_experiment_saved_metrics"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="integration",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("slack", "Slack"),
|
||||
("salesforce", "Salesforce"),
|
||||
("hubspot", "Hubspot"),
|
||||
("google-pubsub", "Google Pubsub"),
|
||||
("google-cloud-storage", "Google Cloud Storage"),
|
||||
("google-ads", "Google Ads"),
|
||||
("snapchat", "Snapchat"),
|
||||
("linkedin-ads", "Linkedin Ads"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1 +1 @@
|
||||
0557_add_tags_to_experiment_saved_metrics
|
||||
0558_alter_integration_kind
|
||||
|
||||
@@ -50,6 +50,7 @@ class Integration(models.Model):
|
||||
GOOGLE_CLOUD_STORAGE = "google-cloud-storage"
|
||||
GOOGLE_ADS = "google-ads"
|
||||
SNAPCHAT = "snapchat"
|
||||
LINKEDIN_ADS = "linkedin-ads"
|
||||
|
||||
team = models.ForeignKey("Team", on_delete=models.CASCADE)
|
||||
|
||||
@@ -116,7 +117,7 @@ class OauthConfig:
|
||||
|
||||
|
||||
class OauthIntegration:
|
||||
supported_kinds = ["slack", "salesforce", "hubspot", "google-ads", "snapchat"]
|
||||
supported_kinds = ["slack", "salesforce", "hubspot", "google-ads", "snapchat", "linkedin-ads"]
|
||||
integration: Integration
|
||||
|
||||
def __init__(self, integration: Integration) -> None:
|
||||
@@ -210,6 +211,21 @@ class OauthIntegration:
|
||||
id_path="me.id",
|
||||
name_path="me.email",
|
||||
)
|
||||
elif kind == "linkedin-ads":
|
||||
if not settings.LINKEDIN_APP_CLIENT_ID or not settings.LINKEDIN_APP_CLIENT_SECRET:
|
||||
raise NotImplementedError("LinkedIn Ads app not configured")
|
||||
|
||||
return OauthConfig(
|
||||
authorize_url="https://www.linkedin.com/oauth/v2/authorization",
|
||||
token_info_url="https://api.linkedin.com/v2/userinfo",
|
||||
token_info_config_fields=["sub", "email"],
|
||||
token_url="https://www.linkedin.com/oauth/v2/accessToken",
|
||||
client_id=settings.LINKEDIN_APP_CLIENT_ID,
|
||||
client_secret=settings.LINKEDIN_APP_CLIENT_SECRET,
|
||||
scope="r_ads rw_conversions openid profile email",
|
||||
id_path="sub",
|
||||
name_path="email",
|
||||
)
|
||||
|
||||
raise NotImplementedError(f"Oauth config for kind {kind} not implemented")
|
||||
|
||||
@@ -594,3 +610,43 @@ class GoogleCloudIntegration:
|
||||
reload_integrations_on_workers(self.integration.team_id, [self.integration.id])
|
||||
|
||||
logger.info(f"Refreshed access token for {self}")
|
||||
|
||||
|
||||
class LinkedInAdsIntegration:
|
||||
integration: Integration
|
||||
|
||||
def __init__(self, integration: Integration) -> None:
|
||||
if integration.kind != "linkedin-ads":
|
||||
raise Exception("LinkedInAdsIntegration init called with Integration with wrong 'kind'")
|
||||
|
||||
self.integration = integration
|
||||
|
||||
@property
|
||||
def client(self) -> WebClient:
|
||||
return WebClient(self.integration.sensitive_config["access_token"])
|
||||
|
||||
def list_linkedin_ads_conversion_rules(self, account_id) -> list[dict]:
|
||||
response = requests.request(
|
||||
"GET",
|
||||
f"https://api.linkedin.com/rest/conversions?q=account&account=urn%3Ali%3AsponsoredAccount%3A{account_id}&fields=conversionMethod%2Cenabled%2Ctype%2Cname%2Cid%2Ccampaigns%2CattributionType",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.integration.sensitive_config['access_token']}",
|
||||
"LinkedIn-Version": "202409",
|
||||
},
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
def list_linkedin_ads_accounts(self) -> dict:
|
||||
response = requests.request(
|
||||
"GET",
|
||||
"https://api.linkedin.com/v2/adAccountsV2?q=search",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.integration.sensitive_config['access_token']}",
|
||||
"LinkedIn-Version": "202409",
|
||||
},
|
||||
)
|
||||
|
||||
return response.json()
|
||||
|
||||
@@ -9,6 +9,9 @@ SNAPCHAT_APP_CLIENT_SECRET = get_from_env("SNAPCHAT_APP_CLIENT_SECRET", "")
|
||||
SALESFORCE_CONSUMER_KEY = get_from_env("SALESFORCE_CONSUMER_KEY", "")
|
||||
SALESFORCE_CONSUMER_SECRET = get_from_env("SALESFORCE_CONSUMER_SECRET", "")
|
||||
|
||||
LINKEDIN_APP_CLIENT_ID = get_from_env("LINKEDIN_APP_CLIENT_ID", "")
|
||||
LINKEDIN_APP_CLIENT_SECRET = get_from_env("LINKEDIN_APP_CLIENT_SECRET", "")
|
||||
|
||||
GOOGLE_ADS_APP_CLIENT_ID = get_from_env("GOOGLE_ADS_APP_CLIENT_ID", "")
|
||||
GOOGLE_ADS_APP_CLIENT_SECRET = get_from_env("GOOGLE_ADS_APP_CLIENT_SECRET", "")
|
||||
GOOGLE_ADS_DEVELOPER_TOKEN = get_from_env("GOOGLE_ADS_DEVELOPER_TOKEN", "")
|
||||
|
||||
Reference in New Issue
Block a user