diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--default--dark.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--default--dark.png index ec8494f1cb683..30d6848c0dcbf 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-input-select--default--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-input-select--default--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-input-select--default--light.png b/frontend/__snapshots__/lemon-ui-lemon-input-select--default--light.png index 5496b3c4488fa..115f6d83d8d7f 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-input-select--default--light.png and b/frontend/__snapshots__/lemon-ui-lemon-input-select--default--light.png differ diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts index 30dc587df6482..80f26932101e1 100644 --- a/frontend/src/lib/utils/eventUsageLogic.ts +++ b/frontend/src/lib/utils/eventUsageLogic.ts @@ -571,15 +571,6 @@ export const eventUsageLogic = kea([ reportSurveyTemplateClicked: (template: SurveyTemplateType) => ({ template }), reportSurveyCycleDetected: (survey: Survey | NewSurvey) => ({ survey }), reportProductUnsubscribed: (product: string) => ({ product }), - // onboarding - reportOnboardingProductSelected: ( - productKey: string, - includeFirstOnboardingProductOnUserProperties: boolean - ) => ({ - productKey, - includeFirstOnboardingProductOnUserProperties, - }), - reportOnboardingCompleted: (productKey: string) => ({ productKey }), reportSubscribedDuringOnboarding: (productKey: string) => ({ productKey }), // command bar reportCommandBarStatusChanged: (status: BarStatus) => ({ status }), @@ -1300,21 +1291,6 @@ export const eventUsageLogic = kea([ }) }, // onboarding - reportOnboardingProductSelected: ({ productKey, includeFirstOnboardingProductOnUserProperties }) => { - posthog.capture('onboarding product selected', { - product_key: productKey, - $set_once: { - first_onboarding_product_selected: includeFirstOnboardingProductOnUserProperties - ? productKey - : undefined, - }, - }) - }, - reportOnboardingCompleted: ({ productKey }) => { - posthog.capture('onboarding completed', { - product_key: productKey, - }) - }, reportSubscribedDuringOnboarding: ({ productKey }) => { posthog.capture('subscribed during onboarding', { product_key: productKey, diff --git a/frontend/src/scenes/onboarding/OnboardingProductIntroduction.tsx b/frontend/src/scenes/onboarding/OnboardingProductIntroduction.tsx index 8a410d91471dc..caf8777bf025d 100644 --- a/frontend/src/scenes/onboarding/OnboardingProductIntroduction.tsx +++ b/frontend/src/scenes/onboarding/OnboardingProductIntroduction.tsx @@ -2,15 +2,13 @@ import { IconCheck, IconMap, IconMessage, IconStack } from '@posthog/icons' import { LemonButton, Link, Spinner } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { WavingHog } from 'lib/components/hedgehogs' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import posthog from 'posthog-js' import React from 'react' import { convertLargeNumberToWords } from 'scenes/billing/billing-utils' import { billingProductLogic } from 'scenes/billing/billingProductLogic' import { ProductPricingModal } from 'scenes/billing/ProductPricingModal' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { getProductIcon } from 'scenes/products/Products' -import { userLogic } from 'scenes/userLogic' +import { teamLogic } from 'scenes/teamLogic' import { BillingFeatureType, BillingProductV2Type, ProductKey } from '~/types' @@ -43,8 +41,7 @@ export const Subfeature = ({ name, description, icon_key }: BillingFeatureType): } const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.Element => { - const { user } = useValues(userLogic) - const { reportOnboardingProductSelected } = useActions(eventUsageLogic) + const { addProductIntent } = useActions(teamLogic) const { completeOnboarding, setTeamPropertiesForProduct, goToNextStep } = useActions(onboardingLogic) const { isFirstProductOnboarding } = useValues(onboardingLogic) const { hasSnippetEvents } = useValues(sdksLogic) @@ -53,9 +50,6 @@ const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.E [ProductKey.FEATURE_FLAGS]: 'Create a feature flag or experiment', [ProductKey.SURVEYS]: 'Create a survey', } - const includeFirstOnboardingProductOnUserProperties = user?.date_joined - ? new Date(user?.date_joined) > new Date('2024-01-10T00:00:00Z') - : false return (
@@ -69,7 +63,10 @@ const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.E className="max-w-max" onClick={() => { setTeamPropertiesForProduct(product.type as ProductKey) - reportOnboardingProductSelected(product.type, includeFirstOnboardingProductOnUserProperties) + addProductIntent({ + product_type: product.type as ProductKey, + intent_context: 'onboarding product selected', + }) goToNextStep() }} > @@ -86,8 +83,10 @@ const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.E className="max-w-max" onClick={() => { setTeamPropertiesForProduct(product.type as ProductKey) - reportOnboardingProductSelected(product.type, includeFirstOnboardingProductOnUserProperties) - posthog.capture('product onboarding skipped', { product_key: product.type }) + addProductIntent({ + product_type: product.type as ProductKey, + intent_context: 'onboarding product selected', + }) completeOnboarding() }} > @@ -99,10 +98,10 @@ const GetStartedButton = ({ product }: { product: BillingProductV2Type }): JSX.E data-attr="start-onboarding-sdk" onClick={() => { setTeamPropertiesForProduct(product.type as ProductKey) - reportOnboardingProductSelected( - product.type, - includeFirstOnboardingProductOnUserProperties - ) + addProductIntent({ + product_type: product.type as ProductKey, + intent_context: 'onboarding product selected', + }) goToNextStep() }} > diff --git a/frontend/src/scenes/onboarding/onboardingLogic.tsx b/frontend/src/scenes/onboarding/onboardingLogic.tsx index ae98f4ddf9af9..d997ed737197c 100644 --- a/frontend/src/scenes/onboarding/onboardingLogic.tsx +++ b/frontend/src/scenes/onboarding/onboardingLogic.tsx @@ -133,7 +133,12 @@ export const onboardingLogic = kea([ preflightLogic, ['isCloudOrDev'], ], - actions: [billingLogic, ['loadBillingSuccess'], teamLogic, ['updateCurrentTeam', 'updateCurrentTeamSuccess']], + actions: [ + billingLogic, + ['loadBillingSuccess'], + teamLogic, + ['updateCurrentTeam', 'updateCurrentTeamSuccess', 'recordProductIntentOnboardingComplete'], + ], }), actions({ setProduct: (product: OnboardingProduct | null) => ({ product }), @@ -346,18 +351,18 @@ export const onboardingLogic = kea([ actions.setOnCompleteOnboardingRedirectUrl(redirectUrlOverride) } if (values.productKey) { - const product = values.productKey - eventUsageLogic.actions.reportOnboardingCompleted(product) - if (nextProductKey) { - actions.setProductKey(nextProductKey) - router.actions.push(urls.onboarding(nextProductKey)) - } + const productKey = values.productKey + actions.recordProductIntentOnboardingComplete({ product_type: productKey as ProductKey }) teamLogic.actions.updateCurrentTeam({ has_completed_onboarding_for: { ...values.currentTeam?.has_completed_onboarding_for, - [product]: true, + [productKey]: true, }, }) + if (nextProductKey) { + actions.setProductKey(nextProductKey) + router.actions.push(urls.onboarding(nextProductKey)) + } } }, setAllOnboardingSteps: () => { diff --git a/frontend/src/scenes/products/Products.tsx b/frontend/src/scenes/products/Products.tsx index 5515af5f9d73b..23d9e0ad6399d 100644 --- a/frontend/src/scenes/products/Products.tsx +++ b/frontend/src/scenes/products/Products.tsx @@ -4,13 +4,11 @@ import clsx from 'clsx' import { useActions, useValues } from 'kea' import { router } from 'kea-router' import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { availableOnboardingProducts, getProductUri, onboardingLogic } from 'scenes/onboarding/onboardingLogic' import { SceneExport } from 'scenes/sceneTypes' import { inviteLogic } from 'scenes/settings/organization/inviteLogic' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' -import { userLogic } from 'scenes/userLogic' import { OnboardingProduct, ProductKey } from '~/types' @@ -38,8 +36,7 @@ export function ProductCard({ }): JSX.Element { const { currentTeam } = useValues(teamLogic) const { setIncludeIntro } = useActions(onboardingLogic) - const { user } = useValues(userLogic) - const { reportOnboardingProductSelected } = useActions(eventUsageLogic) + const { addProductIntent } = useActions(teamLogic) const onboardingCompleted = currentTeam?.has_completed_onboarding_for?.[productKey] const vertical = orientation === 'vertical' @@ -51,13 +48,13 @@ export function ProductCard({ onClick={() => { setIncludeIntro(false) if (!onboardingCompleted) { - const includeFirstOnboardingProductOnUserProperties = user?.date_joined - ? new Date(user?.date_joined) > new Date('2024-01-10T00:00:00Z') - : false - reportOnboardingProductSelected(productKey, includeFirstOnboardingProductOnUserProperties) getStartedActionOverride && getStartedActionOverride() } router.actions.push(urls.onboarding(productKey)) + addProductIntent({ + product_type: productKey as ProductKey, + intent_context: 'onboarding product selected', + }) }} > {onboardingCompleted && ( diff --git a/frontend/src/scenes/teamLogic.tsx b/frontend/src/scenes/teamLogic.tsx index 6ae6348cf6318..2ebf076409061 100644 --- a/frontend/src/scenes/teamLogic.tsx +++ b/frontend/src/scenes/teamLogic.tsx @@ -9,7 +9,7 @@ import { identifierToHuman, isUserLoggedIn, resolveWebhookService } from 'lib/ut import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { getAppContext } from 'lib/utils/getAppContext' -import { CorrelationConfigType, ProjectType, TeamPublicType, TeamType } from '~/types' +import { CorrelationConfigType, ProductKey, ProjectType, TeamPublicType, TeamType } from '~/types' import { organizationLogic } from './organizationLogic' import { projectLogic } from './projectLogic' @@ -143,6 +143,20 @@ export const teamLogic = kea([ return await api.create(`api/projects/${values.currentProject.id}/environments/`, { name, is_demo }) }, resetToken: async () => await api.update(`api/environments/${values.currentTeamId}/reset_token`, {}), + addProductIntent: async ({ + product_type, + }: { + product_type: ProductKey + intent_context?: string | null + }) => + await api.update(`api/environments/${values.currentTeamId}/add_product_intent`, { + product_type, + intent_context: null, + }), + recordProductIntentOnboardingComplete: async ({ product_type }: { product_type: ProductKey }) => + await api.update(`api/environments/${values.currentTeamId}/complete_product_onboarding`, { + product_type, + }), }, ], })), diff --git a/latest_migrations.manifest b/latest_migrations.manifest index 3e8e6156d35ee..7307753ccc3da 100644 --- a/latest_migrations.manifest +++ b/latest_migrations.manifest @@ -5,7 +5,7 @@ contenttypes: 0002_remove_content_type_name ee: 0016_rolemembership_organization_member otp_static: 0002_throttling otp_totp: 0002_auto_20190420_0723 -posthog: 0483_datawarehousesavedquery_table +posthog: 0484_productintent sessions: 0001_initial social_django: 0010_uid_db_index two_factor: 0007_auto_20201201_1019 diff --git a/posthog/api/project.py b/posthog/api/project.py index 8b43bb1e46b3f..e517d20a3c826 100644 --- a/posthog/api/project.py +++ b/posthog/api/project.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import UTC, datetime, timedelta from functools import cached_property from typing import Any, Optional, cast @@ -8,11 +8,15 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated -from posthog.geoip import get_geoip_properties from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import ProjectBackwardCompatBasicSerializer -from posthog.api.team import PremiumMultiProjectPermissions, TeamSerializer, validate_team_attrs +from posthog.api.team import ( + PremiumMultiProjectPermissions, + TeamSerializer, + validate_team_attrs, +) from posthog.event_usage import report_user_action +from posthog.geoip import get_geoip_properties from posthog.jwt import PosthogJwtAudience, encode_jwt from posthog.models import User from posthog.models.activity_logging.activity_log import ( @@ -26,6 +30,7 @@ from posthog.models.group_type_mapping import GroupTypeMapping from posthog.models.organization import OrganizationMembership from posthog.models.personal_api_key import APIScopeObjectOrNotSupported +from posthog.models.product_intent.product_intent import ProductIntent from posthog.models.project import Project from posthog.models.signals import mute_selected_signals from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data @@ -52,6 +57,7 @@ class ProjectBackwardCompatSerializer(ProjectBackwardCompatBasicSerializer, User effective_membership_level = serializers.SerializerMethodField() # Compat with TeamSerializer has_group_types = serializers.SerializerMethodField() # Compat with TeamSerializer live_events_token = serializers.SerializerMethodField() # Compat with TeamSerializer + product_intents = serializers.SerializerMethodField() # Compat with TeamSerializer class Meta: model = Project @@ -105,6 +111,7 @@ class Meta: "has_completed_onboarding_for", # Compat with TeamSerializer "surveys_opt_in", # Compat with TeamSerializer "heatmaps_opt_in", # Compat with TeamSerializer + "product_intents", # Compat with TeamSerializer ) read_only_fields = ( "id", @@ -119,6 +126,7 @@ class Meta: "ingested_event", "default_modifiers", "person_on_events_querying_enabled", + "product_intents", ) team_passthrough_fields = { @@ -181,6 +189,11 @@ def get_live_events_token(self, project: Project) -> Optional[str]: PosthogJwtAudience.LIVESTREAM, ) + def get_product_intents(self, obj): + project = obj + team = project.passthrough_team + return ProductIntent.objects.filter(team=team).values("product_type", "created_at", "onboarding_completed_at") + @staticmethod def validate_session_recording_linked_flag(value) -> dict | None: return TeamSerializer.validate_session_recording_linked_flag(value) @@ -501,6 +514,86 @@ def activity(self, request: request.Request, **kwargs): ) return activity_page_response(activity_page, limit, page, request) + @action( + methods=["PATCH"], + detail=True, + ) + def add_product_intent(self, request: request.Request, *args, **kwargs): + project = self.get_object() + team = project.passthrough_team + user = request.user + product_type = request.data.get("product_type") + current_url = request.headers.get("Referer") + session_id = request.headers.get("X-Posthog-Session-Id") + + if not product_type: + return response.Response({"error": "product_type is required"}, status=400) + + product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type) + if not created: + product_intent.updated_at = datetime.now(tz=UTC) + product_intent.save() + + if created and isinstance(user, User): + report_user_action( + user, + "user showed product intent", + { + "product_key": product_type, + "$set_once": {"first_onboarding_product_selected": product_type}, + "$current_url": current_url, + "$session_id": session_id, + "intent_context": request.data.get("intent_context"), + }, + team=team, + ) + + return response.Response(TeamSerializer(team, context=self.get_serializer_context()).data, status=201) + + @action(methods=["PATCH"], detail=True) + def complete_product_onboarding(self, request: request.Request, *args, **kwargs): + project = self.get_object() + team = project.passthrough_team + product_type = request.data.get("product_type") + user = request.user + current_url = request.headers.get("Referer") + session_id = request.headers.get("X-Posthog-Session-Id") + + if not product_type: + return response.Response({"error": "product_type is required"}, status=400) + + product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type) + + if created and isinstance(user, User): + report_user_action( + user, + "user showed product intent", + { + "product_key": product_type, + "$set_once": {"first_onboarding_product_selected": product_type}, + "$current_url": current_url, + "$session_id": session_id, + "intent_context": request.data.get("intent_context"), + }, + team=team, + ) + product_intent.onboarding_completed_at = datetime.now(tz=UTC) + product_intent.save() + + if isinstance(user, User): # typing + report_user_action( + user, + "product onboarding completed", + { + "product_key": product_type, + "$current_url": current_url, + "$session_id": session_id, + }, + team=team, + ) + + return response.Response(TeamSerializer(team, context=self.get_serializer_context()).data) + @cached_property def user_permissions(self): project = self.get_object() if self.action == "reset_token" else None diff --git a/posthog/api/team.py b/posthog/api/team.py index 10fb2bcee964b..182eec7b1a59a 100644 --- a/posthog/api/team.py +++ b/posthog/api/team.py @@ -1,22 +1,22 @@ import json +from datetime import UTC, datetime, timedelta from functools import cached_property from typing import Any, Optional, cast -from datetime import timedelta from uuid import UUID from django.shortcuts import get_object_or_404 from loginas.utils import is_impersonated_session -from posthog.jwt import PosthogJwtAudience, encode_jwt -from rest_framework.permissions import BasePermission, IsAuthenticated -from posthog.api.utils import action from rest_framework import exceptions, request, response, serializers, viewsets +from rest_framework.permissions import BasePermission, IsAuthenticated -from posthog.geoip import get_geoip_properties from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import TeamBasicSerializer +from posthog.api.utils import action from posthog.constants import AvailableFeature from posthog.event_usage import report_user_action -from posthog.models import Team, User +from posthog.geoip import get_geoip_properties +from posthog.jwt import PosthogJwtAudience, encode_jwt +from posthog.models import ProductIntent, Team, User from posthog.models.activity_logging.activity_log import ( Detail, dict_changes_between, @@ -117,6 +117,7 @@ class TeamSerializer(serializers.ModelSerializer, UserPermissionsSerializerMixin effective_membership_level = serializers.SerializerMethodField() has_group_types = serializers.SerializerMethodField() live_events_token = serializers.SerializerMethodField() + product_intents = serializers.SerializerMethodField() class Meta: model = Team @@ -171,6 +172,7 @@ class Meta: "surveys_opt_in", "heatmaps_opt_in", "live_events_token", + "product_intents", ) read_only_fields = ( "id", @@ -201,6 +203,9 @@ def get_live_events_token(self, team: Team) -> Optional[str]: PosthogJwtAudience.LIVESTREAM, ) + def get_product_intents(self, obj): + return ProductIntent.objects.filter(team=obj).values("product_type", "created_at", "onboarding_completed_at") + @staticmethod def validate_session_recording_linked_flag(value) -> dict | None: if value is None: @@ -512,6 +517,84 @@ def activity(self, request: request.Request, **kwargs): ) return activity_page_response(activity_page, limit, page, request) + @action( + methods=["PATCH"], + detail=True, + ) + def add_product_intent(self, request: request.Request, *args, **kwargs): + team = self.get_object() + user = request.user + product_type = request.data.get("product_type") + current_url = request.headers.get("Referer") + session_id = request.headers.get("X-Posthog-Session-Id") + + if not product_type: + return response.Response({"error": "product_type is required"}, status=400) + + product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type) + if not created: + product_intent.updated_at = datetime.now(tz=UTC) + product_intent.save() + + if created and isinstance(user, User): + report_user_action( + user, + "user showed product intent", + { + "product_key": product_type, + "$set_once": {"first_onboarding_product_selected": product_type}, + "$current_url": current_url, + "$session_id": session_id, + "intent_context": request.data.get("intent_context"), + }, + team=team, + ) + + return response.Response(TeamSerializer(team, context=self.get_serializer_context()).data, status=201) + + @action(methods=["PATCH"], detail=True) + def complete_product_onboarding(self, request: request.Request, *args, **kwargs): + team = self.get_object() + product_type = request.data.get("product_type") + user = request.user + current_url = request.headers.get("Referer") + session_id = request.headers.get("X-Posthog-Session-Id") + + if not product_type: + return response.Response({"error": "product_type is required"}, status=400) + + product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type) + + if created and isinstance(user, User): + report_user_action( + user, + "user showed product intent", + { + "product_key": product_type, + "$set_once": {"first_onboarding_product_selected": product_type}, + "$current_url": current_url, + "$session_id": session_id, + "intent_context": request.data.get("intent_context"), + }, + team=team, + ) + product_intent.onboarding_completed_at = datetime.now(tz=UTC) + product_intent.save() + + if isinstance(user, User): # typing + report_user_action( + user, + "product onboarding completed", + { + "product_key": product_type, + "$current_url": current_url, + "$session_id": session_id, + }, + team=team, + ) + + return response.Response(TeamSerializer(team, context=self.get_serializer_context()).data) + @cached_property def user_permissions(self): team = self.get_object() if self.action == "reset_token" else None diff --git a/posthog/api/test/__snapshots__/test_api_docs.ambr b/posthog/api/test/__snapshots__/test_api_docs.ambr index 7dad6294c2105..59745fc27236d 100644 --- a/posthog/api/test/__snapshots__/test_api_docs.ambr +++ b/posthog/api/test/__snapshots__/test_api_docs.ambr @@ -53,6 +53,7 @@ '/home/runner/work/posthog/posthog/posthog/api/plugin.py: Warning [PipelineDestinationsViewSet > PluginSerializer]: unable to resolve type hint for function "get_hog_function_migration_available". Consider using a type hint or @extend_schema_field. Defaulting to string.', '/home/runner/work/posthog/posthog/posthog/api/project.py: Warning [ProjectViewSet > ProjectBackwardCompatSerializer]: could not resolve field on model with path "person_on_events_querying_enabled". This is likely a custom field that does some unknown magic. Maybe consider annotating the field/property? Defaulting to "string". (Exception: Project has no field named \'person_on_events_querying_enabled\')', '/home/runner/work/posthog/posthog/posthog/api/project.py: Warning [ProjectViewSet > ProjectBackwardCompatSerializer]: could not resolve field on model with path "default_modifiers". This is likely a custom field that does some unknown magic. Maybe consider annotating the field/property? Defaulting to "string". (Exception: Project has no field named \'default_modifiers\')', + '/home/runner/work/posthog/posthog/posthog/api/project.py: Warning [ProjectViewSet > ProjectBackwardCompatSerializer]: unable to resolve type hint for function "get_product_intents". Consider using a type hint or @extend_schema_field. Defaulting to string.', '/home/runner/work/posthog/posthog/posthog/api/proxy_record.py: Warning [ProxyRecordViewset]: could not derive type of path parameter "organization_id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. ) or annotating the parameter type with @extend_schema. Defaulting to "string".', '/home/runner/work/posthog/posthog/posthog/api/proxy_record.py: Warning [ProxyRecordViewset]: could not derive type of path parameter "id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. ) or annotating the parameter type with @extend_schema. Defaulting to "string".', '/home/runner/work/posthog/posthog/ee/api/role.py: Warning [RoleViewSet > RoleSerializer]: unable to resolve type hint for function "get_members". Consider using a type hint or @extend_schema_field. Defaulting to string.', @@ -67,6 +68,7 @@ '/home/runner/work/posthog/posthog/posthog/api/dashboards/dashboard.py: Warning [DashboardsViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.dashboard.Dashboard" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', '/home/runner/work/posthog/posthog/ee/api/dashboard_collaborator.py: Warning [DashboardCollaboratorViewSet]: could not derive type of path parameter "project_id" because model "ee.models.dashboard_privilege.DashboardPrivilege" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', '/home/runner/work/posthog/posthog/posthog/api/early_access_feature.py: Warning [EarlyAccessFeatureViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.early_access_feature.EarlyAccessFeature" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', + '/home/runner/work/posthog/posthog/posthog/api/team.py: Warning [TeamViewSet > TeamSerializer]: unable to resolve type hint for function "get_product_intents". Consider using a type hint or @extend_schema_field. Defaulting to string.', "/home/runner/work/posthog/posthog/posthog/api/event_definition.py: Error [EventDefinitionViewSet]: exception raised while getting serializer. Hint: Is get_serializer_class() returning None or is get_queryset() not working without a request? Ignoring the view for now. (Exception: 'AnonymousUser' object has no attribute 'organization')", '/home/runner/work/posthog/posthog/posthog/api/event_definition.py: Warning [EventDefinitionViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.event_definition.EventDefinition" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', '/home/runner/work/posthog/posthog/ee/clickhouse/views/experiments.py: Warning [EnterpriseExperimentsViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.experiment.Experiment" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', diff --git a/posthog/api/test/__snapshots__/test_decide.ambr b/posthog/api/test/__snapshots__/test_decide.ambr index 1fb64299a0957..55d6d13064021 100644 --- a/posthog/api/test/__snapshots__/test_decide.ambr +++ b/posthog/api/test/__snapshots__/test_decide.ambr @@ -271,6 +271,15 @@ ''' # --- # name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.14 + ''' + SELECT "posthog_productintent"."product_type", + "posthog_productintent"."created_at", + "posthog_productintent"."onboarding_completed_at" + FROM "posthog_productintent" + WHERE "posthog_productintent"."team_id" = 2 + ''' +# --- +# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.15 ''' SELECT "posthog_user"."id", "posthog_user"."password", @@ -302,7 +311,7 @@ LIMIT 21 ''' # --- -# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.15 +# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.16 ''' SELECT "posthog_featureflag"."id", "posthog_featureflag"."key", @@ -325,7 +334,7 @@ AND "posthog_featureflag"."team_id" = 2) ''' # --- -# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.16 +# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.17 ''' SELECT "posthog_pluginconfig"."id", "posthog_pluginconfig"."web_token", diff --git a/posthog/api/test/test_team.py b/posthog/api/test/test_team.py index 4bcbf05a796ef..405197ecc637d 100644 --- a/posthog/api/test/test_team.py +++ b/posthog/api/test/test_team.py @@ -1,4 +1,5 @@ import json +from datetime import UTC, datetime from typing import Any, Optional from unittest import mock from unittest.mock import ANY, MagicMock, call, patch @@ -17,6 +18,7 @@ from posthog.models.dashboard import Dashboard from posthog.models.instance_setting import get_instance_setting from posthog.models.organization import Organization, OrganizationMembership +from posthog.models.product_intent import ProductIntent from posthog.models.team import Team from posthog.temporal.common.client import sync_connect from posthog.temporal.common.schedule import describe_schedule @@ -991,6 +993,66 @@ def test_can_set_replay_configs_patch_session_replay_config_one_level_deep(self) # and the existing second level nesting is not preserved self._assert_replay_config_is({"ai_config": {"opt_in": None, "included_event_properties": ["and another"]}}) + @patch("posthog.api.project.report_user_action") + @patch("posthog.api.team.report_user_action") + @freeze_time("2024-01-01T00:00:00Z") + def test_can_add_product_intent( + self, mock_report_user_action: MagicMock, mock_report_user_action_legacy_endpoint: MagicMock + ) -> None: + if self.client_class is EnvironmentToProjectRewriteClient: + mock_report_user_action = mock_report_user_action_legacy_endpoint + response = self.client.patch( + f"/api/environments/{self.team.id}/add_product_intent/", + {"product_type": "product_analytics", "intent_context": "onboarding product selected"}, + headers={"Referer": "https://posthogtest.com/my-url", "X-Posthog-Session-Id": "test_session_id"}, + ) + assert response.status_code == status.HTTP_201_CREATED + product_intent = ProductIntent.objects.get(team=self.team, product_type="product_analytics") + assert product_intent.created_at == datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + mock_report_user_action.assert_called_once_with( + self.user, + "user showed product intent", + { + "product_key": "product_analytics", + "$current_url": "https://posthogtest.com/my-url", + "$session_id": "test_session_id", + "intent_context": "onboarding product selected", + "$set_once": {"first_onboarding_product_selected": "product_analytics"}, + }, + team=self.team, + ) + + @patch("posthog.api.project.report_user_action") + @patch("posthog.api.team.report_user_action") + def test_can_complete_product_onboarding( + self, mock_report_user_action: MagicMock, mock_report_user_action_legacy_endpoint: MagicMock + ) -> None: + if self.client_class is EnvironmentToProjectRewriteClient: + mock_report_user_action = mock_report_user_action_legacy_endpoint + with freeze_time("2024-01-01T00:00:00Z"): + product_intent = ProductIntent.objects.create(team=self.team, product_type="product_analytics") + assert product_intent.created_at == datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + assert product_intent.onboarding_completed_at is None + with freeze_time("2024-01-05T00:00:00Z"): + response = self.client.patch( + f"/api/environments/{self.team.id}/complete_product_onboarding/", + {"product_type": "product_analytics"}, + headers={"Referer": "https://posthogtest.com/my-url", "X-Posthog-Session-Id": "test_session_id"}, + ) + assert response.status_code == status.HTTP_200_OK + product_intent = ProductIntent.objects.get(team=self.team, product_type="product_analytics") + assert product_intent.onboarding_completed_at == datetime(2024, 1, 5, 0, 0, 0, tzinfo=UTC) + mock_report_user_action.assert_called_once_with( + self.user, + "product onboarding completed", + { + "product_key": "product_analytics", + "$current_url": "https://posthogtest.com/my-url", + "$session_id": "test_session_id", + }, + team=self.team, + ) + def _assert_replay_config_is(self, expected: dict[str, Any] | None) -> HttpResponse: get_response = self.client.get("/api/environments/@current/") assert get_response.status_code == status.HTTP_200_OK, get_response.json() diff --git a/posthog/migrations/0484_productintent.py b/posthog/migrations/0484_productintent.py new file mode 100644 index 0000000000000..42d35087bde78 --- /dev/null +++ b/posthog/migrations/0484_productintent.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.15 on 2024-10-06 21:02 + +from django.db import migrations, models +import django.db.models.deletion +import posthog.models.utils + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0483_datawarehousesavedquery_table"), + ] + + operations = [ + migrations.CreateModel( + name="ProductIntent", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("product_type", models.CharField(max_length=255)), + ("onboarding_completed_at", models.DateTimeField(blank=True, null=True)), + ("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")), + ], + options={ + "unique_together": {("team", "product_type")}, + }, + ), + ] diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index e3a4942335044..5a57fbe5c2c51 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -35,7 +35,6 @@ from .event_definition import EventDefinition from .event_property import EventProperty from .experiment import Experiment -from .web_experiment import WebExperiment from .exported_asset import ExportedAsset from .feature_flag import FeatureFlag from .feedback.survey import Survey @@ -62,6 +61,7 @@ PluginLogEntry, PluginSourceFile, ) +from .product_intent import ProductIntent from .project import Project from .property import Property from .property_definition import PropertyDefinition @@ -75,6 +75,7 @@ from .uploaded_media import UploadedMedia from .user import User, UserManager from .user_scene_personalisation import UserScenePersonalisation +from .web_experiment import WebExperiment __all__ = [ "AlertConfiguration", @@ -134,6 +135,7 @@ "PluginConfig", "PluginLogEntry", "PluginSourceFile", + "ProductIntent", "Project", "Property", "PropertyDefinition", diff --git a/posthog/models/product_intent/__init__.py b/posthog/models/product_intent/__init__.py new file mode 100644 index 0000000000000..cf06b647383f6 --- /dev/null +++ b/posthog/models/product_intent/__init__.py @@ -0,0 +1 @@ +from .product_intent import * diff --git a/posthog/models/product_intent/product_intent.py b/posthog/models/product_intent/product_intent.py new file mode 100644 index 0000000000000..0dab4cc8115d9 --- /dev/null +++ b/posthog/models/product_intent/product_intent.py @@ -0,0 +1,17 @@ +from django.db import models + +from posthog.models.utils import UUIDModel + + +class ProductIntent(UUIDModel): + team = models.ForeignKey("Team", on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + product_type = models.CharField(max_length=255) + onboarding_completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = ["team", "product_type"] + + def __str__(self): + return f"{self.team.name} - {self.product_type}"