From 6093ac457f1272206ff3ecab6f483f4c3dcedf39 Mon Sep 17 00:00:00 2001 From: Marcus Hof <13001502+MarconLP@users.noreply.github.com> Date: Fri, 31 Jan 2025 16:33:32 +0100 Subject: [PATCH] feat(cdp): add linkedin integration (#26282) Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- frontend/public/services/linkedin.png | Bin 0 -> 7283 bytes frontend/src/lib/api.ts | 26 +++ .../LinkedInIntegrationHelpers.tsx | 151 +++++++++++++++++ .../src/lib/integrations/integrationsLogic.ts | 2 + .../linkedInAdsIntegrationLogic.ts | 37 +++++ .../HogFunctionInputIntegrationField.tsx | 23 +++ frontend/src/types.ts | 12 ++ greptile.json | 4 +- posthog/api/integration.py | 34 ++++ posthog/cdp/templates/__init__.py | 2 + .../linkedin_ads/template_linkedin_ads.py | 155 ++++++++++++++++++ .../test_template_linkedin_ads.py | 66 ++++++++ .../migrations/0558_alter_integration_kind.py | 29 ++++ posthog/migrations/max_migration.txt | 2 +- posthog/models/integration.py | 58 ++++++- posthog/settings/integrations.py | 3 + 16 files changed, 599 insertions(+), 5 deletions(-) create mode 100644 frontend/public/services/linkedin.png create mode 100644 frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx create mode 100644 frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts create mode 100644 posthog/cdp/templates/linkedin_ads/template_linkedin_ads.py create mode 100644 posthog/cdp/templates/linkedin_ads/test_template_linkedin_ads.py create mode 100644 posthog/migrations/0558_alter_integration_kind.py diff --git a/frontend/public/services/linkedin.png b/frontend/public/services/linkedin.png new file mode 100644 index 0000000000000000000000000000000000000000..355dd141b9edcba674a9884b311e78f8e4d1d32b GIT binary patch literal 7283 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 IK)}iW01rr@IRF3v literal 0 HcmV?d00001 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d51b9ff1eb..7a5b6711a5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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: { diff --git a/frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx b/frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx new file mode 100644 index 0000000000..b3f2f20aa0 --- /dev/null +++ b/frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx @@ -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: ( + + {account.name} ({account.id.toString()}) + + ), + label: `${account.name}`, + })) + : null +} + +const getLinkedInAdsConversionRuleOptions = ( + linkedInAdsConversionRules?: LinkedInAdsConversionRuleType[] | null +): LemonInputSelectOption[] | null => { + return linkedInAdsConversionRules + ? linkedInAdsConversionRules.map(({ id, name }) => ({ + key: id.toString(), + labelComponent: ( + + {name} ({id}) + + ), + 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 ( + <> + 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 ( + <> + 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} + /> + + ) +} diff --git a/frontend/src/lib/integrations/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts index 3e83f31c01..6779a1b2f6 100644 --- a/frontend/src/lib/integrations/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -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 = { 'google-cloud-storage': IconGoogleCloudStorage, 'google-ads': IconGoogleAds, snapchat: IconSnapchat, + 'linkedin-ads': IconLinkedIn, } export const integrationsLogic = kea([ diff --git a/frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts b/frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts new file mode 100644 index 0000000000..451b684915 --- /dev/null +++ b/frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts @@ -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([ + 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 + }, + }, + ], + })), +]) diff --git a/frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx b/frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx index a8900c03ac..ec77df5833 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx @@ -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 ( + onChange?.(x?.split('|')[0])} + integration={integration} + /> + ) + } + if (schema.integration_field === 'linkedin_ads_account_id') { + return ( + onChange?.(x?.split('|')[0])} + integration={integration} + /> + ) + } return (

Unsupported integration type: {schema.integration}

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f679e0fab7..b343f4ac6b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 diff --git a/greptile.json b/greptile.json index 647f1bf80f..61699e5ef7 100644 --- a/greptile.json +++ b/greptile.json @@ -1,5 +1,3 @@ { - "disabledLabels": [ - "no-greptile" - ] + "disabledLabels": ["no-greptile"] } diff --git a/posthog/api/integration.py b/posthog/api/integration.py index 18ddba4d31..5a702430e7 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -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}) diff --git a/posthog/cdp/templates/__init__.py b/posthog/cdp/templates/__init__.py index ca81f89155..7d92420dfb 100644 --- a/posthog/cdp/templates/__init__.py +++ b/posthog/cdp/templates/__init__.py @@ -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, diff --git a/posthog/cdp/templates/linkedin_ads/template_linkedin_ads.py b/posthog/cdp/templates/linkedin_ads/template_linkedin_ads.py new file mode 100644 index 0000000000..651476d39a --- /dev/null +++ b/posthog/cdp/templates/linkedin_ads/template_linkedin_ads.py @@ -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, + }, +) diff --git a/posthog/cdp/templates/linkedin_ads/test_template_linkedin_ads.py b/posthog/cdp/templates/linkedin_ads/test_template_linkedin_ads.py new file mode 100644 index 0000000000..a96c3d4ccf --- /dev/null +++ b/posthog/cdp/templates/linkedin_ads/test_template_linkedin_ads.py @@ -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", + }, + }, + ) + ) diff --git a/posthog/migrations/0558_alter_integration_kind.py b/posthog/migrations/0558_alter_integration_kind.py new file mode 100644 index 0000000000..df04063b9e --- /dev/null +++ b/posthog/migrations/0558_alter_integration_kind.py @@ -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, + ), + ), + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index b9d0d107a6..bd089fc2b9 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -0557_add_tags_to_experiment_saved_metrics +0558_alter_integration_kind diff --git a/posthog/models/integration.py b/posthog/models/integration.py index 7af4332732..0d6b4d8b88 100644 --- a/posthog/models/integration.py +++ b/posthog/models/integration.py @@ -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() diff --git a/posthog/settings/integrations.py b/posthog/settings/integrations.py index 66a4d6ce8e..166cacbde0 100644 --- a/posthog/settings/integrations.py +++ b/posthog/settings/integrations.py @@ -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", "")