diff --git a/frontend/src/scenes/teamLogic.tsx b/frontend/src/scenes/teamLogic.tsx index 0e6754609ca..cc630ed721a 100644 --- a/frontend/src/scenes/teamLogic.tsx +++ b/frontend/src/scenes/teamLogic.tsx @@ -143,6 +143,9 @@ 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`, {}), + /** + * If adding a product intent that also represents regular product usage, see explainer in posthog.models.product_intent.product_intent.py. + */ addProductIntent: async ({ product_type, intent_context, diff --git a/latest_migrations.manifest b/latest_migrations.manifest index dd3901be254..23c51f37f70 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: 0505_grouptypemapping_project +posthog: 0506_productintent_activated_at_and_more 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 c740dc33080..429bb262674 100644 --- a/posthog/api/project.py +++ b/posthog/api/project.py @@ -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.group_type_mapping import GroupTypeMapping from posthog.models.organization import OrganizationMembership -from posthog.models.scopes import APIScopeObjectOrNotSupported -from posthog.models.product_intent.product_intent import ProductIntent +from posthog.models.product_intent.product_intent import ( + ProductIntent, + calculate_product_activation, +) from posthog.models.project import Project +from posthog.models.scopes import APIScopeObjectOrNotSupported from posthog.models.signals import mute_selected_signals from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data from posthog.models.utils import UUIDT @@ -199,6 +202,7 @@ class ProjectBackwardCompatSerializer(ProjectBackwardCompatBasicSerializer, User def get_product_intents(self, obj): project = obj 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( "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) 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.save() - if isinstance(user, User): + if isinstance(user, User) and not product_intent.activated_at: report_user_action( user, "user showed product intent", diff --git a/posthog/api/team.py b/posthog/api/team.py index f03e217f549..257a10f459d 100644 --- a/posthog/api/team.py +++ b/posthog/api/team.py @@ -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.group_type_mapping import GroupTypeMapping 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.scopes import APIScopeObjectOrNotSupported from posthog.models.signals import mute_selected_signals from posthog.models.team.util import delete_batch_exports, delete_bulky_postgres_data from posthog.models.utils import UUIDT @@ -217,6 +218,7 @@ class TeamSerializer(serializers.ModelSerializer, UserPermissionsSerializerMixin ) 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( "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) 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.save() - if isinstance(user, User): + if isinstance(user, User) and not product_intent.activated_at: report_user_action( user, "user showed product intent", diff --git a/posthog/api/test/__snapshots__/test_decide.ambr b/posthog/api/test/__snapshots__/test_decide.ambr index df39b283048..1156d6822ce 100644 --- a/posthog/api/test/__snapshots__/test_decide.ambr +++ b/posthog/api/test/__snapshots__/test_decide.ambr @@ -64,6 +64,30 @@ ''' # --- # 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", "posthog_user"."password", @@ -95,7 +119,7 @@ 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", "posthog_featureflag"."key", @@ -118,7 +142,7 @@ 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", "posthog_pluginconfig"."web_token", @@ -134,74 +158,6 @@ 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 ''' SELECT "posthog_user"."id", @@ -523,12 +479,64 @@ # --- # name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.9 ''' - 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 + 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"."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 diff --git a/posthog/api/test/test_team.py b/posthog/api/test/test_team.py index 3a881facfea..6992c1822ef 100644 --- a/posthog/api/test/test_team.py +++ b/posthog/api/test/test_team.py @@ -1051,6 +1051,76 @@ def team_api_test_factory(): 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.team.report_user_action") def test_can_complete_product_onboarding( diff --git a/posthog/migrations/0506_productintent_activated_at_and_more.py b/posthog/migrations/0506_productintent_activated_at_and_more.py new file mode 100644 index 00000000000..a9d273f5aff --- /dev/null +++ b/posthog/migrations/0506_productintent_activated_at_and_more.py @@ -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, + ), + ), + ] diff --git a/posthog/models/product_intent/product_intent.py b/posthog/models/product_intent/product_intent.py index 0dab4cc8115..85ec938eb11 100644 --- a/posthog/models/product_intent/product_intent.py +++ b/posthog/models/product_intent/product_intent.py @@ -1,6 +1,36 @@ +from datetime import UTC, datetime + +from celery import shared_task 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.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): @@ -9,9 +39,78 @@ class ProductIntent(UUIDModel): updated_at = models.DateTimeField(auto_now=True) product_type = models.CharField(max_length=255) 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: unique_together = ["team", "product_type"] def __str__(self): 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() diff --git a/posthog/models/test/test_product_intent.py b/posthog/models/test/test_product_intent.py new file mode 100644 index 00000000000..81b05f23269 --- /dev/null +++ b/posthog/models/test/test_product_intent.py @@ -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) diff --git a/posthog/test/test_middleware.py b/posthog/test/test_middleware.py index 507c247d5b0..2788ad6503c 100644 --- a/posthog/test/test_middleware.py +++ b/posthog/test/test_middleware.py @@ -1,14 +1,14 @@ -from datetime import datetime, timedelta import json +from datetime import datetime, timedelta from urllib.parse import quote from django.test.client import Client from django.urls import reverse from freezegun import freeze_time from rest_framework import status + from posthog.api.test.test_organization import create_organization from posthog.api.test.test_team import create_team - from posthog.models import Action, Cohort, Dashboard, FeatureFlag, Insight from posthog.models.organization import Organization from posthog.models.team import Team @@ -142,7 +142,7 @@ class TestAutoProjectMiddleware(APIBaseTest): @classmethod def setUpTestData(cls): super().setUpTestData() - cls.base_app_num_queries = 45 + cls.base_app_num_queries = 47 # Create another team that the user does have access to cls.second_team = create_team(organization=cls.organization, name="Second Life")