mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-24 00:47:50 +01:00
feat: only send product_intent events if not activated (#25889)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
003ac8a029
commit
54efcdcbc0
@ -143,6 +143,9 @@ export const teamLogic = kea<teamLogicType>([
|
|||||||
return await api.create(`api/projects/${values.currentProject.id}/environments/`, { name, is_demo })
|
return await api.create(`api/projects/${values.currentProject.id}/environments/`, { name, is_demo })
|
||||||
},
|
},
|
||||||
resetToken: async () => await api.update(`api/environments/${values.currentTeamId}/reset_token`, {}),
|
resetToken: async () => await api.update(`api/environments/${values.currentTeamId}/reset_token`, {}),
|
||||||
|
/**
|
||||||
|
* If adding a product intent that also represents regular product usage, see explainer in posthog.models.product_intent.product_intent.py.
|
||||||
|
*/
|
||||||
addProductIntent: async ({
|
addProductIntent: async ({
|
||||||
product_type,
|
product_type,
|
||||||
intent_context,
|
intent_context,
|
||||||
|
@ -5,7 +5,7 @@ contenttypes: 0002_remove_content_type_name
|
|||||||
ee: 0016_rolemembership_organization_member
|
ee: 0016_rolemembership_organization_member
|
||||||
otp_static: 0002_throttling
|
otp_static: 0002_throttling
|
||||||
otp_totp: 0002_auto_20190420_0723
|
otp_totp: 0002_auto_20190420_0723
|
||||||
posthog: 0505_grouptypemapping_project
|
posthog: 0506_productintent_activated_at_and_more
|
||||||
sessions: 0001_initial
|
sessions: 0001_initial
|
||||||
social_django: 0010_uid_db_index
|
social_django: 0010_uid_db_index
|
||||||
two_factor: 0007_auto_20201201_1019
|
two_factor: 0007_auto_20201201_1019
|
||||||
|
@ -30,9 +30,12 @@ from posthog.models.activity_logging.activity_page import activity_page_response
|
|||||||
from posthog.models.async_deletion import AsyncDeletion, DeletionType
|
from posthog.models.async_deletion import AsyncDeletion, DeletionType
|
||||||
from posthog.models.group_type_mapping import GroupTypeMapping
|
from posthog.models.group_type_mapping import GroupTypeMapping
|
||||||
from posthog.models.organization import OrganizationMembership
|
from posthog.models.organization import OrganizationMembership
|
||||||
from posthog.models.scopes import APIScopeObjectOrNotSupported
|
from posthog.models.product_intent.product_intent import (
|
||||||
from posthog.models.product_intent.product_intent import ProductIntent
|
ProductIntent,
|
||||||
|
calculate_product_activation,
|
||||||
|
)
|
||||||
from posthog.models.project import Project
|
from posthog.models.project import Project
|
||||||
|
from posthog.models.scopes import APIScopeObjectOrNotSupported
|
||||||
from posthog.models.signals import mute_selected_signals
|
from posthog.models.signals import mute_selected_signals
|
||||||
from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data
|
from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data
|
||||||
from posthog.models.utils import UUIDT
|
from posthog.models.utils import UUIDT
|
||||||
@ -199,6 +202,7 @@ class ProjectBackwardCompatSerializer(ProjectBackwardCompatBasicSerializer, User
|
|||||||
def get_product_intents(self, obj):
|
def get_product_intents(self, obj):
|
||||||
project = obj
|
project = obj
|
||||||
team = project.passthrough_team
|
team = project.passthrough_team
|
||||||
|
calculate_product_activation.delay(team.id, only_calc_if_days_since_last_checked=1)
|
||||||
return ProductIntent.objects.filter(team=team).values(
|
return ProductIntent.objects.filter(team=team).values(
|
||||||
"product_type", "created_at", "onboarding_completed_at", "updated_at"
|
"product_type", "created_at", "onboarding_completed_at", "updated_at"
|
||||||
)
|
)
|
||||||
@ -575,10 +579,12 @@ class ProjectViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
|
|||||||
|
|
||||||
product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type)
|
product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type)
|
||||||
if not created:
|
if not created:
|
||||||
|
if not product_intent.activated_at:
|
||||||
|
product_intent.check_and_update_activation()
|
||||||
product_intent.updated_at = datetime.now(tz=UTC)
|
product_intent.updated_at = datetime.now(tz=UTC)
|
||||||
product_intent.save()
|
product_intent.save()
|
||||||
|
|
||||||
if isinstance(user, User):
|
if isinstance(user, User) and not product_intent.activated_at:
|
||||||
report_user_action(
|
report_user_action(
|
||||||
user,
|
user,
|
||||||
"user showed product intent",
|
"user showed product intent",
|
||||||
|
@ -28,8 +28,9 @@ from posthog.models.activity_logging.activity_page import activity_page_response
|
|||||||
from posthog.models.async_deletion import AsyncDeletion, DeletionType
|
from posthog.models.async_deletion import AsyncDeletion, DeletionType
|
||||||
from posthog.models.group_type_mapping import GroupTypeMapping
|
from posthog.models.group_type_mapping import GroupTypeMapping
|
||||||
from posthog.models.organization import OrganizationMembership
|
from posthog.models.organization import OrganizationMembership
|
||||||
from posthog.models.scopes import APIScopeObjectOrNotSupported
|
from posthog.models.product_intent.product_intent import calculate_product_activation
|
||||||
from posthog.models.project import Project
|
from posthog.models.project import Project
|
||||||
|
from posthog.models.scopes import APIScopeObjectOrNotSupported
|
||||||
from posthog.models.signals import mute_selected_signals
|
from posthog.models.signals import mute_selected_signals
|
||||||
from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data
|
from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data
|
||||||
from posthog.models.utils import UUIDT
|
from posthog.models.utils import UUIDT
|
||||||
@ -217,6 +218,7 @@ class TeamSerializer(serializers.ModelSerializer, UserPermissionsSerializerMixin
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_product_intents(self, obj):
|
def get_product_intents(self, obj):
|
||||||
|
calculate_product_activation.delay(obj.id, only_calc_if_days_since_last_checked=1)
|
||||||
return ProductIntent.objects.filter(team=obj).values(
|
return ProductIntent.objects.filter(team=obj).values(
|
||||||
"product_type", "created_at", "onboarding_completed_at", "updated_at"
|
"product_type", "created_at", "onboarding_completed_at", "updated_at"
|
||||||
)
|
)
|
||||||
@ -587,10 +589,12 @@ class TeamViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
|
|||||||
|
|
||||||
product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type)
|
product_intent, created = ProductIntent.objects.get_or_create(team=team, product_type=product_type)
|
||||||
if not created:
|
if not created:
|
||||||
|
if not product_intent.activated_at:
|
||||||
|
product_intent.check_and_update_activation()
|
||||||
product_intent.updated_at = datetime.now(tz=UTC)
|
product_intent.updated_at = datetime.now(tz=UTC)
|
||||||
product_intent.save()
|
product_intent.save()
|
||||||
|
|
||||||
if isinstance(user, User):
|
if isinstance(user, User) and not product_intent.activated_at:
|
||||||
report_user_action(
|
report_user_action(
|
||||||
user,
|
user,
|
||||||
"user showed product intent",
|
"user showed product intent",
|
||||||
|
@ -64,6 +64,30 @@
|
|||||||
'''
|
'''
|
||||||
# ---
|
# ---
|
||||||
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.10
|
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.10
|
||||||
|
'''
|
||||||
|
SELECT "posthog_productintent"."id",
|
||||||
|
"posthog_productintent"."team_id",
|
||||||
|
"posthog_productintent"."created_at",
|
||||||
|
"posthog_productintent"."updated_at",
|
||||||
|
"posthog_productintent"."product_type",
|
||||||
|
"posthog_productintent"."onboarding_completed_at",
|
||||||
|
"posthog_productintent"."activated_at",
|
||||||
|
"posthog_productintent"."activation_last_checked_at"
|
||||||
|
FROM "posthog_productintent"
|
||||||
|
WHERE "posthog_productintent"."team_id" = 99999
|
||||||
|
'''
|
||||||
|
# ---
|
||||||
|
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.11
|
||||||
|
'''
|
||||||
|
SELECT "posthog_productintent"."product_type",
|
||||||
|
"posthog_productintent"."created_at",
|
||||||
|
"posthog_productintent"."onboarding_completed_at",
|
||||||
|
"posthog_productintent"."updated_at"
|
||||||
|
FROM "posthog_productintent"
|
||||||
|
WHERE "posthog_productintent"."team_id" = 99999
|
||||||
|
'''
|
||||||
|
# ---
|
||||||
|
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.12
|
||||||
'''
|
'''
|
||||||
SELECT "posthog_user"."id",
|
SELECT "posthog_user"."id",
|
||||||
"posthog_user"."password",
|
"posthog_user"."password",
|
||||||
@ -95,7 +119,7 @@
|
|||||||
LIMIT 21
|
LIMIT 21
|
||||||
'''
|
'''
|
||||||
# ---
|
# ---
|
||||||
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.11
|
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.13
|
||||||
'''
|
'''
|
||||||
SELECT "posthog_featureflag"."id",
|
SELECT "posthog_featureflag"."id",
|
||||||
"posthog_featureflag"."key",
|
"posthog_featureflag"."key",
|
||||||
@ -118,7 +142,7 @@
|
|||||||
AND "posthog_featureflag"."team_id" = 99999)
|
AND "posthog_featureflag"."team_id" = 99999)
|
||||||
'''
|
'''
|
||||||
# ---
|
# ---
|
||||||
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.12
|
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.14
|
||||||
'''
|
'''
|
||||||
SELECT "posthog_pluginconfig"."id",
|
SELECT "posthog_pluginconfig"."id",
|
||||||
"posthog_pluginconfig"."web_token",
|
"posthog_pluginconfig"."web_token",
|
||||||
@ -134,74 +158,6 @@
|
|||||||
AND "posthog_pluginconfig"."team_id" = 99999)
|
AND "posthog_pluginconfig"."team_id" = 99999)
|
||||||
'''
|
'''
|
||||||
# ---
|
# ---
|
||||||
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.13
|
|
||||||
'''
|
|
||||||
SELECT "posthog_team"."id",
|
|
||||||
"posthog_team"."uuid",
|
|
||||||
"posthog_team"."organization_id",
|
|
||||||
"posthog_team"."project_id",
|
|
||||||
"posthog_team"."api_token",
|
|
||||||
"posthog_team"."app_urls",
|
|
||||||
"posthog_team"."name",
|
|
||||||
"posthog_team"."slack_incoming_webhook",
|
|
||||||
"posthog_team"."created_at",
|
|
||||||
"posthog_team"."updated_at",
|
|
||||||
"posthog_team"."anonymize_ips",
|
|
||||||
"posthog_team"."completed_snippet_onboarding",
|
|
||||||
"posthog_team"."has_completed_onboarding_for",
|
|
||||||
"posthog_team"."ingested_event",
|
|
||||||
"posthog_team"."autocapture_opt_out",
|
|
||||||
"posthog_team"."autocapture_web_vitals_opt_in",
|
|
||||||
"posthog_team"."autocapture_web_vitals_allowed_metrics",
|
|
||||||
"posthog_team"."autocapture_exceptions_opt_in",
|
|
||||||
"posthog_team"."autocapture_exceptions_errors_to_ignore",
|
|
||||||
"posthog_team"."session_recording_opt_in",
|
|
||||||
"posthog_team"."session_recording_sample_rate",
|
|
||||||
"posthog_team"."session_recording_minimum_duration_milliseconds",
|
|
||||||
"posthog_team"."session_recording_linked_flag",
|
|
||||||
"posthog_team"."session_recording_network_payload_capture_config",
|
|
||||||
"posthog_team"."session_replay_config",
|
|
||||||
"posthog_team"."survey_config",
|
|
||||||
"posthog_team"."capture_console_log_opt_in",
|
|
||||||
"posthog_team"."capture_performance_opt_in",
|
|
||||||
"posthog_team"."surveys_opt_in",
|
|
||||||
"posthog_team"."heatmaps_opt_in",
|
|
||||||
"posthog_team"."session_recording_version",
|
|
||||||
"posthog_team"."signup_token",
|
|
||||||
"posthog_team"."is_demo",
|
|
||||||
"posthog_team"."access_control",
|
|
||||||
"posthog_team"."week_start_day",
|
|
||||||
"posthog_team"."inject_web_apps",
|
|
||||||
"posthog_team"."test_account_filters",
|
|
||||||
"posthog_team"."test_account_filters_default_checked",
|
|
||||||
"posthog_team"."path_cleaning_filters",
|
|
||||||
"posthog_team"."timezone",
|
|
||||||
"posthog_team"."data_attributes",
|
|
||||||
"posthog_team"."person_display_name_properties",
|
|
||||||
"posthog_team"."live_events_columns",
|
|
||||||
"posthog_team"."recording_domains",
|
|
||||||
"posthog_team"."primary_dashboard_id",
|
|
||||||
"posthog_team"."extra_settings",
|
|
||||||
"posthog_team"."modifiers",
|
|
||||||
"posthog_team"."correlation_config",
|
|
||||||
"posthog_team"."session_recording_retention_period_days",
|
|
||||||
"posthog_team"."external_data_workspace_id",
|
|
||||||
"posthog_team"."external_data_workspace_last_synced_at"
|
|
||||||
FROM "posthog_team"
|
|
||||||
WHERE ("posthog_team"."project_id" = 2
|
|
||||||
AND "posthog_team"."id" = 2)
|
|
||||||
LIMIT 21
|
|
||||||
'''
|
|
||||||
# ---
|
|
||||||
# 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
|
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.15
|
||||||
'''
|
'''
|
||||||
SELECT "posthog_user"."id",
|
SELECT "posthog_user"."id",
|
||||||
@ -523,12 +479,64 @@
|
|||||||
# ---
|
# ---
|
||||||
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.9
|
# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.9
|
||||||
'''
|
'''
|
||||||
SELECT "posthog_productintent"."product_type",
|
SELECT "posthog_team"."id",
|
||||||
"posthog_productintent"."created_at",
|
"posthog_team"."uuid",
|
||||||
"posthog_productintent"."onboarding_completed_at",
|
"posthog_team"."organization_id",
|
||||||
"posthog_productintent"."updated_at"
|
"posthog_team"."project_id",
|
||||||
FROM "posthog_productintent"
|
"posthog_team"."api_token",
|
||||||
WHERE "posthog_productintent"."team_id" = 99999
|
"posthog_team"."app_urls",
|
||||||
|
"posthog_team"."name",
|
||||||
|
"posthog_team"."slack_incoming_webhook",
|
||||||
|
"posthog_team"."created_at",
|
||||||
|
"posthog_team"."updated_at",
|
||||||
|
"posthog_team"."anonymize_ips",
|
||||||
|
"posthog_team"."completed_snippet_onboarding",
|
||||||
|
"posthog_team"."has_completed_onboarding_for",
|
||||||
|
"posthog_team"."ingested_event",
|
||||||
|
"posthog_team"."autocapture_opt_out",
|
||||||
|
"posthog_team"."autocapture_web_vitals_opt_in",
|
||||||
|
"posthog_team"."autocapture_web_vitals_allowed_metrics",
|
||||||
|
"posthog_team"."autocapture_exceptions_opt_in",
|
||||||
|
"posthog_team"."autocapture_exceptions_errors_to_ignore",
|
||||||
|
"posthog_team"."person_processing_opt_out",
|
||||||
|
"posthog_team"."session_recording_opt_in",
|
||||||
|
"posthog_team"."session_recording_sample_rate",
|
||||||
|
"posthog_team"."session_recording_minimum_duration_milliseconds",
|
||||||
|
"posthog_team"."session_recording_linked_flag",
|
||||||
|
"posthog_team"."session_recording_network_payload_capture_config",
|
||||||
|
"posthog_team"."session_recording_url_trigger_config",
|
||||||
|
"posthog_team"."session_recording_url_blocklist_config",
|
||||||
|
"posthog_team"."session_replay_config",
|
||||||
|
"posthog_team"."survey_config",
|
||||||
|
"posthog_team"."capture_console_log_opt_in",
|
||||||
|
"posthog_team"."capture_performance_opt_in",
|
||||||
|
"posthog_team"."capture_dead_clicks",
|
||||||
|
"posthog_team"."surveys_opt_in",
|
||||||
|
"posthog_team"."heatmaps_opt_in",
|
||||||
|
"posthog_team"."session_recording_version",
|
||||||
|
"posthog_team"."signup_token",
|
||||||
|
"posthog_team"."is_demo",
|
||||||
|
"posthog_team"."access_control",
|
||||||
|
"posthog_team"."week_start_day",
|
||||||
|
"posthog_team"."inject_web_apps",
|
||||||
|
"posthog_team"."test_account_filters",
|
||||||
|
"posthog_team"."test_account_filters_default_checked",
|
||||||
|
"posthog_team"."path_cleaning_filters",
|
||||||
|
"posthog_team"."timezone",
|
||||||
|
"posthog_team"."data_attributes",
|
||||||
|
"posthog_team"."person_display_name_properties",
|
||||||
|
"posthog_team"."live_events_columns",
|
||||||
|
"posthog_team"."recording_domains",
|
||||||
|
"posthog_team"."primary_dashboard_id",
|
||||||
|
"posthog_team"."extra_settings",
|
||||||
|
"posthog_team"."modifiers",
|
||||||
|
"posthog_team"."correlation_config",
|
||||||
|
"posthog_team"."session_recording_retention_period_days",
|
||||||
|
"posthog_team"."external_data_workspace_id",
|
||||||
|
"posthog_team"."external_data_workspace_last_synced_at"
|
||||||
|
FROM "posthog_team"
|
||||||
|
WHERE "posthog_team"."id" = 99999
|
||||||
|
LIMIT 21
|
||||||
'''
|
'''
|
||||||
# ---
|
# ---
|
||||||
# name: TestDecide.test_flag_with_behavioural_cohorts
|
# name: TestDecide.test_flag_with_behavioural_cohorts
|
||||||
|
@ -1051,6 +1051,76 @@ def team_api_test_factory():
|
|||||||
team=self.team,
|
team=self.team,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("posthog.api.team.calculate_product_activation.delay", MagicMock())
|
||||||
|
@patch("posthog.models.product_intent.ProductIntent.check_and_update_activation")
|
||||||
|
@patch("posthog.api.project.report_user_action")
|
||||||
|
@patch("posthog.api.team.report_user_action")
|
||||||
|
@freeze_time("2024-01-01T00:00:00Z")
|
||||||
|
def test_can_update_product_intent_if_already_exists(
|
||||||
|
self,
|
||||||
|
mock_report_user_action: MagicMock,
|
||||||
|
mock_report_user_action_legacy_endpoint: MagicMock,
|
||||||
|
mock_check_and_update_activation: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
intent = ProductIntent.objects.create(team=self.team, product_type="product_analytics")
|
||||||
|
original_created_at = intent.created_at
|
||||||
|
assert original_created_at == datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
# change the time of the existing intent
|
||||||
|
with freeze_time("2024-01-02T00:00:00Z"):
|
||||||
|
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"},
|
||||||
|
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.updated_at == datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC)
|
||||||
|
assert product_intent.created_at == original_created_at
|
||||||
|
mock_check_and_update_activation.assert_called_once()
|
||||||
|
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": None,
|
||||||
|
"$set_once": {"first_onboarding_product_selected": "product_analytics"},
|
||||||
|
"is_first_intent_for_product": False,
|
||||||
|
"intent_created_at": datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC),
|
||||||
|
"intent_updated_at": datetime(2024, 1, 2, 0, 0, 0, tzinfo=UTC),
|
||||||
|
"realm": get_instance_realm(),
|
||||||
|
},
|
||||||
|
team=self.team,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("posthog.api.team.calculate_product_activation.delay", MagicMock())
|
||||||
|
@patch("posthog.models.product_intent.ProductIntent.check_and_update_activation")
|
||||||
|
@patch("posthog.api.project.report_user_action")
|
||||||
|
@patch("posthog.api.team.report_user_action")
|
||||||
|
@freeze_time("2024-01-05T00:00:00Z")
|
||||||
|
def test_doesnt_send_event_for_already_activated_intent(
|
||||||
|
self,
|
||||||
|
mock_report_user_action: MagicMock,
|
||||||
|
mock_report_user_action_legacy_endpoint: MagicMock,
|
||||||
|
mock_check_and_update_activation: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
ProductIntent.objects.create(
|
||||||
|
team=self.team, product_type="product_analytics", activated_at=datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
|
)
|
||||||
|
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"},
|
||||||
|
headers={"Referer": "https://posthogtest.com/my-url", "X-Posthog-Session-Id": "test_session_id"},
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
mock_check_and_update_activation.assert_not_called()
|
||||||
|
mock_report_user_action.assert_not_called()
|
||||||
|
|
||||||
@patch("posthog.api.project.report_user_action")
|
@patch("posthog.api.project.report_user_action")
|
||||||
@patch("posthog.api.team.report_user_action")
|
@patch("posthog.api.team.report_user_action")
|
||||||
def test_can_complete_product_onboarding(
|
def test_can_complete_product_onboarding(
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 4.2.15 on 2024-11-01 16:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("posthog", "0505_grouptypemapping_project"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="productintent",
|
||||||
|
name="activated_at",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The date the org completed activation for the product. Generally only used to know if we should continue updating the product_intent row.",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="productintent",
|
||||||
|
name="activation_last_checked_at",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The date we last checked if the org had completed activation for the product.",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -1,6 +1,36 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
from posthog.models.insight import Insight
|
||||||
|
from posthog.models.team.team import Team
|
||||||
from posthog.models.utils import UUIDModel
|
from posthog.models.utils import UUIDModel
|
||||||
|
from posthog.utils import get_instance_realm
|
||||||
|
|
||||||
|
"""
|
||||||
|
How to use this model:
|
||||||
|
|
||||||
|
Product intents are indicators that someone showed an interest in a given product.
|
||||||
|
They are triggered from the frontend when the user performs certain actions, like
|
||||||
|
selecting a product during onboarding or clicking on a certain button.
|
||||||
|
|
||||||
|
Some buttons that show product intent are frequently used by all users of the product,
|
||||||
|
so we need to know if it's a new product intent, or if it's just regular usage. We
|
||||||
|
can use the `activated_at` field to know if we should continue to update the product
|
||||||
|
intent row, or if we should stop because it's just regular usage.
|
||||||
|
|
||||||
|
The `activated_at` field is set by checking against certain criteria that differs for
|
||||||
|
each product. For instance, for the data warehouse product, we check if the user has
|
||||||
|
created any DataVisualizationNode insights in the 30 days after the product intent
|
||||||
|
was created. Each product needs to implement a method that checks for activation
|
||||||
|
criteria if the intent actions are the same as the general usage actions.
|
||||||
|
|
||||||
|
We shouldn't use this model and the `activated_at` field in place of sending events
|
||||||
|
about product usage because that limits our data exploration later. Definitely continue
|
||||||
|
sending events for product usage that we may want to track for any reason, along with
|
||||||
|
calculating activation here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class ProductIntent(UUIDModel):
|
class ProductIntent(UUIDModel):
|
||||||
@ -9,9 +39,78 @@ class ProductIntent(UUIDModel):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
product_type = models.CharField(max_length=255)
|
product_type = models.CharField(max_length=255)
|
||||||
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
|
onboarding_completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
activated_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="The date the org completed activation for the product. Generally only used to know if we should continue updating the product_intent row.",
|
||||||
|
)
|
||||||
|
activation_last_checked_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="The date we last checked if the org had completed activation for the product.",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ["team", "product_type"]
|
unique_together = ["team", "product_type"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.team.name} - {self.product_type}"
|
return f"{self.team.name} - {self.product_type}"
|
||||||
|
|
||||||
|
def has_activated_data_warehouse(self) -> bool:
|
||||||
|
insights = Insight.objects.filter(
|
||||||
|
team=self.team,
|
||||||
|
created_at__gte=datetime(2024, 6, 1, tzinfo=UTC),
|
||||||
|
query__kind="DataVisualizationNode",
|
||||||
|
)
|
||||||
|
|
||||||
|
excluded_tables = ["events", "persons", "sessions", "person_distinct_ids"]
|
||||||
|
for insight in insights:
|
||||||
|
if insight.query and insight.query.get("source", {}).get("query"):
|
||||||
|
query_text = insight.query["source"]["query"].lower()
|
||||||
|
# Check if query doesn't contain any of the excluded tables after 'from'
|
||||||
|
has_excluded_table = any(f"from {table}" in query_text.replace("\\", "") for table in excluded_tables)
|
||||||
|
if not has_excluded_table:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_and_update_activation(self) -> None:
|
||||||
|
if self.product_type == "data_warehouse":
|
||||||
|
if self.has_activated_data_warehouse():
|
||||||
|
self.activated_at = datetime.now(tz=UTC)
|
||||||
|
self.save()
|
||||||
|
self.report_activation("data_warehouse")
|
||||||
|
|
||||||
|
def report_activation(self, product_key: str) -> None:
|
||||||
|
from posthog.event_usage import report_team_action
|
||||||
|
|
||||||
|
report_team_action(
|
||||||
|
self.team,
|
||||||
|
"product intent marked activated",
|
||||||
|
{
|
||||||
|
"product_key": product_key,
|
||||||
|
"intent_created_at": self.created_at,
|
||||||
|
"intent_updated_at": self.updated_at,
|
||||||
|
"realm": get_instance_realm(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(ignore_result=True)
|
||||||
|
def calculate_product_activation(team_id: int, only_calc_if_days_since_last_checked: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Calculate product activation for a team.
|
||||||
|
Only calculate if it's been more than `only_calc_if_days_since_last_checked` days since the last activation check.
|
||||||
|
"""
|
||||||
|
team = Team.objects.get(id=team_id)
|
||||||
|
product_intents = ProductIntent.objects.filter(team=team)
|
||||||
|
for product_intent in product_intents:
|
||||||
|
if product_intent.activated_at:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
product_intent.activation_last_checked_at
|
||||||
|
and (datetime.now(tz=UTC) - product_intent.activation_last_checked_at).days
|
||||||
|
<= only_calc_if_days_since_last_checked
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
product_intent.check_and_update_activation()
|
||||||
|
95
posthog/models/test/test_product_intent.py
Normal file
95
posthog/models/test/test_product_intent.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
from datetime import datetime, timedelta, UTC
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from posthog.models.insight import Insight
|
||||||
|
from posthog.models.product_intent.product_intent import (
|
||||||
|
ProductIntent,
|
||||||
|
calculate_product_activation,
|
||||||
|
)
|
||||||
|
from posthog.test.base import BaseTest
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductIntent(BaseTest):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.product_intent = ProductIntent.objects.create(team=self.team, product_type="data_warehouse")
|
||||||
|
|
||||||
|
def test_str_representation(self):
|
||||||
|
self.assertEqual(str(self.product_intent), f"{self.team.name} - data_warehouse")
|
||||||
|
|
||||||
|
def test_unique_constraint(self):
|
||||||
|
# Test that we can't create duplicate product intents for same team/product
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
ProductIntent.objects.create(team=self.team, product_type="data_warehouse")
|
||||||
|
|
||||||
|
@freeze_time("2024-06-15T12:00:00Z")
|
||||||
|
def test_has_activated_data_warehouse_with_valid_query(self):
|
||||||
|
Insight.objects.create(
|
||||||
|
team=self.team, query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM custom_table"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(self.product_intent.has_activated_data_warehouse())
|
||||||
|
|
||||||
|
@freeze_time("2024-06-15T12:00:00Z")
|
||||||
|
def test_has_activated_data_warehouse_with_excluded_table(self):
|
||||||
|
Insight.objects.create(
|
||||||
|
team=self.team, query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM events"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(self.product_intent.has_activated_data_warehouse())
|
||||||
|
|
||||||
|
@freeze_time("2024-06-15T12:00:00Z")
|
||||||
|
def test_has_activated_data_warehouse_with_old_insight(self):
|
||||||
|
with freeze_time("2024-05-15T12:00:00Z"): # Before June 1st, 2024
|
||||||
|
Insight.objects.create(
|
||||||
|
team=self.team,
|
||||||
|
query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM custom_table"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(self.product_intent.has_activated_data_warehouse())
|
||||||
|
|
||||||
|
@freeze_time("2024-06-15T12:00:00Z")
|
||||||
|
def test_check_and_update_activation_sets_activated_at(self):
|
||||||
|
Insight.objects.create(
|
||||||
|
team=self.team, query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM custom_table"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(self.product_intent.activated_at)
|
||||||
|
self.product_intent.check_and_update_activation()
|
||||||
|
self.product_intent.refresh_from_db()
|
||||||
|
assert self.product_intent.activated_at == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
@freeze_time("2024-06-15T12:00:00Z")
|
||||||
|
def test_calculate_product_activation_task(self):
|
||||||
|
# Create an insight that should trigger activation
|
||||||
|
Insight.objects.create(
|
||||||
|
team=self.team, query={"kind": "DataVisualizationNode", "source": {"query": "SELECT * FROM custom_table"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
calculate_product_activation(self.team.id)
|
||||||
|
|
||||||
|
self.product_intent.refresh_from_db()
|
||||||
|
assert self.product_intent.activated_at == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
def test_calculate_product_activation_respects_check_interval(self):
|
||||||
|
# Set last checked time to recent
|
||||||
|
self.product_intent.activation_last_checked_at = datetime.now(tz=UTC)
|
||||||
|
self.product_intent.save()
|
||||||
|
|
||||||
|
calculate_product_activation(self.team.id, only_calc_if_days_since_last_checked=1)
|
||||||
|
|
||||||
|
self.product_intent.refresh_from_db()
|
||||||
|
self.assertIsNone(self.product_intent.activated_at)
|
||||||
|
|
||||||
|
@freeze_time("2024-06-15T12:00:00Z")
|
||||||
|
def test_calculate_product_activation_skips_activated_products(self):
|
||||||
|
# Set product as already activated
|
||||||
|
self.product_intent.activated_at = datetime.now(tz=UTC)
|
||||||
|
self.product_intent.save()
|
||||||
|
|
||||||
|
with freeze_time(datetime.now(tz=UTC) + timedelta(days=2)):
|
||||||
|
calculate_product_activation(self.team.id)
|
||||||
|
self.product_intent.refresh_from_db()
|
||||||
|
assert self.product_intent.activated_at == datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
@ -1,14 +1,14 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from django.test.client import Client
|
from django.test.client import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from posthog.api.test.test_organization import create_organization
|
from posthog.api.test.test_organization import create_organization
|
||||||
from posthog.api.test.test_team import create_team
|
from posthog.api.test.test_team import create_team
|
||||||
|
|
||||||
from posthog.models import Action, Cohort, Dashboard, FeatureFlag, Insight
|
from posthog.models import Action, Cohort, Dashboard, FeatureFlag, Insight
|
||||||
from posthog.models.organization import Organization
|
from posthog.models.organization import Organization
|
||||||
from posthog.models.team import Team
|
from posthog.models.team import Team
|
||||||
@ -142,7 +142,7 @@ class TestAutoProjectMiddleware(APIBaseTest):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
super().setUpTestData()
|
super().setUpTestData()
|
||||||
cls.base_app_num_queries = 45
|
cls.base_app_num_queries = 47
|
||||||
# Create another team that the user does have access to
|
# Create another team that the user does have access to
|
||||||
cls.second_team = create_team(organization=cls.organization, name="Second Life")
|
cls.second_team = create_team(organization=cls.organization, name="Second Life")
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user