diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx
index 4ba47decd2902..250f42da557cd 100644
--- a/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx
+++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx
@@ -11,6 +11,7 @@ import {
} from 'lib/components/ActivityLog/humanizeActivity'
import { ACTIVITY_PAGE_SIZE } from 'lib/constants'
import { PaginationManual } from 'lib/lemon-ui/PaginationControl'
+import { cohortActivityDescriber } from 'scenes/cohorts/activityDescriptions'
import { dataManagementActivityDescriber } from 'scenes/data-management/dataManagementDescribers'
import { flagActivityDescriber } from 'scenes/feature-flags/activityDescriptions'
import { notebookActivityDescriber } from 'scenes/notebooks/Notebook/notebookActivityDescriber'
@@ -36,6 +37,8 @@ export const describerFor = (logItem?: ActivityLogItem): Describer | undefined =
case ActivityScope.PLUGIN:
case ActivityScope.PLUGIN_CONFIG:
return pluginActivityDescriber
+ case ActivityScope.COHORT:
+ return cohortActivityDescriber
case ActivityScope.INSIGHT:
return insightActivityDescriber
case ActivityScope.PERSON:
diff --git a/frontend/src/scenes/cohorts/activityDescriptions.tsx b/frontend/src/scenes/cohorts/activityDescriptions.tsx
new file mode 100644
index 0000000000000..ceb4726ce7b77
--- /dev/null
+++ b/frontend/src/scenes/cohorts/activityDescriptions.tsx
@@ -0,0 +1,55 @@
+import '../../lib/components/Cards/InsightCard/InsightCard.scss'
+
+import {
+ ActivityLogItem,
+ defaultDescriber,
+ HumanizedChange,
+ userNameForLogItem,
+} from 'lib/components/ActivityLog/humanizeActivity'
+import { Link } from 'lib/lemon-ui/Link'
+import { urls } from 'scenes/urls'
+
+const nameOrLinkToCohort = (id?: string | null, name?: string | null): string | JSX.Element => {
+ const displayName = name || '(empty string)'
+ return id ? {displayName} : displayName
+}
+
+export function cohortActivityDescriber(logItem: ActivityLogItem, asNotification?: boolean): HumanizedChange {
+ if (logItem.scope != 'Cohort') {
+ console.error('cohort describer received a non-cohort activity')
+ return { description: null }
+ }
+
+ if (logItem.activity == 'created') {
+ return {
+ description: (
+ <>
+ {userNameForLogItem(logItem)} created the cohort:{' '}
+ {nameOrLinkToCohort(logItem?.item_id, logItem?.detail.name)}
+ >
+ ),
+ }
+ }
+
+ if (logItem.activity == 'deleted') {
+ return {
+ description: (
+ <>
+ {userNameForLogItem(logItem)} deleted the cohort: {logItem.detail.name}
+ >
+ ),
+ }
+ }
+
+ if (logItem.activity == 'updated') {
+ return {
+ description: (
+ <>
+ {userNameForLogItem(logItem)} updated the cohort:{' '}
+ {nameOrLinkToCohort(logItem?.item_id, logItem?.detail.name)}
+ >
+ ),
+ }
+ }
+ return defaultDescriber(logItem, asNotification, nameOrLinkToCohort(logItem?.detail.short_id))
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index b3cca7bd90f98..b91060b2e20be 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -3686,6 +3686,7 @@ export enum ActivityScope {
SURVEY = 'Survey',
EARLY_ACCESS_FEATURE = 'EarlyAccessFeature',
COMMENT = 'Comment',
+ COHORT = 'Cohort',
TEAM = 'Team',
}
diff --git a/posthog/api/activity_log.py b/posthog/api/activity_log.py
index dd369f94c98fb..5645d038bd6a7 100644
--- a/posthog/api/activity_log.py
+++ b/posthog/api/activity_log.py
@@ -11,7 +11,7 @@
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import UserBasicSerializer
-from posthog.models import ActivityLog, FeatureFlag, Insight, NotificationViewed, User
+from posthog.models import ActivityLog, FeatureFlag, Insight, NotificationViewed, User, Cohort
from posthog.models.comment import Comment
from posthog.models.notebook.notebook import Notebook
@@ -114,6 +114,7 @@ def important_changes(self, request: Request, *args: Any, **kwargs: Any) -> Resp
my_comments = list(
Comment.objects.filter(created_by=user, team_id=self.team.pk).values_list("id", flat=True)
)
+ my_cohorts = list(Cohort.objects.filter(created_by=user, team_id=self.team.pk).values_list("id", flat=True))
# then things they edited
interesting_changes = [
@@ -168,6 +169,17 @@ def important_changes(self, request: Request, *args: Any, **kwargs: Any) -> Resp
.values_list("item_id", flat=True)
)
+ my_changed_cohorts = list(
+ ActivityLog.objects.filter(
+ team_id=self.team.id,
+ activity__in=interesting_changes,
+ user_id=user.pk,
+ scope="Cohort",
+ )
+ .exclude(item_id__in=my_cohorts)
+ .values_list("item_id", flat=True)
+ )
+
last_read_date = NotificationViewed.objects.filter(user=user).first()
last_read_filter = ""
@@ -218,6 +230,7 @@ def important_changes(self, request: Request, *args: Any, **kwargs: Any) -> Resp
& Q(id__in=deduplicated_notebook_activity_ids)
)
| Q(Q(scope="Comment") & Q(item_id__in=my_comments))
+ | Q(Q(scope="Cohort") & Q(item_id__in=my_cohorts))
)
| Q(
# don't want to see creation of these things since that was before the user edited these things
@@ -231,6 +244,7 @@ def important_changes(self, request: Request, *args: Any, **kwargs: Any) -> Resp
& Q(id__in=deduplicated_notebook_activity_ids)
)
| Q(Q(scope="Comment") & Q(item_id__in=my_changed_comments))
+ | Q(Q(scope="Cohort") & Q(item_id__in=my_changed_cohorts))
)
)
)
diff --git a/posthog/api/cohort.py b/posthog/api/cohort.py
index 139f8f3747d24..3e776167b8de4 100644
--- a/posthog/api/cohort.py
+++ b/posthog/api/cohort.py
@@ -2,9 +2,12 @@
from posthog.clickhouse.client.connection import Workload
from django.db import DatabaseError
+from loginas.utils import is_impersonated_session
from sentry_sdk import start_span
import structlog
+from posthog.models.activity_logging.activity_log import log_activity, Detail, changes_between, load_activity
+from posthog.models.activity_logging.activity_page import activity_page_response
from posthog.models.feature_flag.flag_matching import (
FeatureFlagMatcher,
FlagsMatcherCache,
@@ -22,7 +25,7 @@
from django.db.models import QuerySet, Prefetch, prefetch_related_objects, OuterRef, Subquery
from django.db.models.expressions import F
from django.utils import timezone
-from rest_framework import serializers, viewsets
+from rest_framework import serializers, viewsets, request, status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
@@ -403,6 +406,69 @@ def persons(self, request: Request, **kwargs) -> Response:
return Response({"results": serialized_actors, "next": next_url, "previous": previous_url})
+ @action(methods=["GET"], url_path="activity", detail=False, required_scopes=["activity_log:read"])
+ def all_activity(self, request: request.Request, **kwargs):
+ limit = int(request.query_params.get("limit", "10"))
+ page = int(request.query_params.get("page", "1"))
+
+ activity_page = load_activity(scope="Cohort", team_id=self.team_id, limit=limit, page=page)
+
+ return activity_page_response(activity_page, limit, page, request)
+
+ @action(methods=["GET"], detail=True, required_scopes=["activity_log:read"])
+ def activity(self, request: request.Request, **kwargs):
+ limit = int(request.query_params.get("limit", "10"))
+ page = int(request.query_params.get("page", "1"))
+
+ item_id = kwargs["pk"]
+ if not Cohort.objects.filter(id=item_id, team_id=self.team_id).exists():
+ return Response("", status=status.HTTP_404_NOT_FOUND)
+
+ activity_page = load_activity(
+ scope="Cohort",
+ team_id=self.team_id,
+ item_ids=[str(item_id)],
+ limit=limit,
+ page=page,
+ )
+ return activity_page_response(activity_page, limit, page, request)
+
+ def perform_create(self, serializer):
+ serializer.save()
+ log_activity(
+ organization_id=self.organization.id,
+ team_id=self.team_id,
+ user=serializer.context["request"].user,
+ was_impersonated=is_impersonated_session(serializer.context["request"]),
+ item_id=serializer.instance.id,
+ scope="Cohort",
+ activity="created",
+ detail=Detail(name=serializer.instance.name),
+ )
+
+ def perform_update(self, serializer):
+ instance_id = serializer.instance.id
+
+ try:
+ before_update = Cohort.objects.get(pk=instance_id)
+ except Cohort.DoesNotExist:
+ before_update = None
+
+ serializer.save()
+
+ changes = changes_between("Cohort", previous=before_update, current=serializer.instance)
+
+ log_activity(
+ organization_id=self.organization.id,
+ team_id=self.team_id,
+ user=serializer.context["request"].user,
+ was_impersonated=is_impersonated_session(serializer.context["request"]),
+ item_id=instance_id,
+ scope="Cohort",
+ activity="updated",
+ detail=Detail(changes=changes, name=serializer.instance.name),
+ )
+
class LegacyCohortViewSet(CohortViewSet):
derive_current_team_from_user_only = True
diff --git a/posthog/api/test/__snapshots__/test_api_docs.ambr b/posthog/api/test/__snapshots__/test_api_docs.ambr
index 70fccf17e398f..8793984c350a5 100644
--- a/posthog/api/test/__snapshots__/test_api_docs.ambr
+++ b/posthog/api/test/__snapshots__/test_api_docs.ambr
@@ -110,6 +110,7 @@
'Warning: operationId "batch_exports_pause_create" has collisions [(\'/api/organizations/{organization_id}/batch_exports/{id}/pause/\', \'post\'), (\'/api/projects/{project_id}/batch_exports/{id}/pause/\', \'post\')]. resolving with numeral suffixes.',
'Warning: operationId "batch_exports_unpause_create" has collisions [(\'/api/organizations/{organization_id}/batch_exports/{id}/unpause/\', \'post\'), (\'/api/projects/{project_id}/batch_exports/{id}/unpause/\', \'post\')]. resolving with numeral suffixes.',
'Warning: operationId "app_metrics_historical_exports_retrieve" has collisions [(\'/api/projects/{project_id}/app_metrics/{plugin_config_id}/historical_exports/\', \'get\'), (\'/api/projects/{project_id}/app_metrics/{plugin_config_id}/historical_exports/{id}/\', \'get\')]. resolving with numeral suffixes.',
+ 'Warning: operationId "cohorts_activity_retrieve" has collisions [(\'/api/projects/{project_id}/cohorts/{id}/activity/\', \'get\'), (\'/api/projects/{project_id}/cohorts/activity/\', \'get\')]. resolving with numeral suffixes.',
'Warning: operationId "event_definitions_retrieve" has collisions [(\'/api/projects/{project_id}/event_definitions/\', \'get\'), (\'/api/projects/{project_id}/event_definitions/{id}/\', \'get\')]. resolving with numeral suffixes.',
'Warning: operationId "feature_flags_activity_retrieve" has collisions [(\'/api/projects/{project_id}/feature_flags/{id}/activity/\', \'get\'), (\'/api/projects/{project_id}/feature_flags/activity/\', \'get\')]. resolving with numeral suffixes.',
'Warning: operationId "insights_activity_retrieve" has collisions [(\'/api/projects/{project_id}/insights/{id}/activity/\', \'get\'), (\'/api/projects/{project_id}/insights/activity/\', \'get\')]. resolving with numeral suffixes.',
diff --git a/posthog/api/test/__snapshots__/test_insight.ambr b/posthog/api/test/__snapshots__/test_insight.ambr
index 1376e752451f9..972bf8c24e289 100644
--- a/posthog/api/test/__snapshots__/test_insight.ambr
+++ b/posthog/api/test/__snapshots__/test_insight.ambr
@@ -1628,6 +1628,24 @@
LIMIT 21
'''
# ---
+# name: TestInsight.test_listing_insights_does_not_nplus1.30
+ '''
+ SELECT "posthog_taggeditem"."id",
+ "posthog_taggeditem"."tag_id",
+ "posthog_taggeditem"."dashboard_id",
+ "posthog_taggeditem"."insight_id",
+ "posthog_taggeditem"."event_definition_id",
+ "posthog_taggeditem"."property_definition_id",
+ "posthog_taggeditem"."action_id",
+ "posthog_taggeditem"."feature_flag_id"
+ FROM "posthog_taggeditem"
+ WHERE "posthog_taggeditem"."insight_id" IN (1,
+ 2,
+ 3,
+ 4,
+ 5 /* ... */)
+ '''
+# ---
# name: TestInsight.test_listing_insights_does_not_nplus1.4
'''
SELECT "posthog_team"."id",
diff --git a/posthog/api/test/test_cohort.py b/posthog/api/test/test_cohort.py
index 82740d73ed515..14b6b60b51484 100644
--- a/posthog/api/test/test_cohort.py
+++ b/posthog/api/test/test_cohort.py
@@ -1,6 +1,7 @@
import json
from datetime import datetime, timedelta
-from typing import Any
+from typing import Optional, Any
+from unittest import mock
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -34,6 +35,31 @@ class TestCohort(TestExportMixin, ClickhouseTestMixin, APIBaseTest, QueryMatchin
def capture_select_queries(self):
return self.capture_queries(("INSERT INTO cohortpeople", "SELECT", "ALTER", "select", "DELETE"))
+ def _get_cohort_activity(
+ self,
+ flag_id: Optional[int] = None,
+ team_id: Optional[int] = None,
+ expected_status: int = status.HTTP_200_OK,
+ ):
+ if team_id is None:
+ team_id = self.team.id
+
+ if flag_id:
+ url = f"/api/projects/{team_id}/cohorts/{flag_id}/activity"
+ else:
+ url = f"/api/projects/{team_id}/cohorts/activity"
+
+ activity = self.client.get(url)
+ self.assertEqual(activity.status_code, expected_status)
+ return activity.json()
+
+ def assert_cohort_activity(self, cohort_id: Optional[int], expected: list[dict]):
+ activity_response = self._get_cohort_activity(cohort_id)
+
+ activity: list[dict] = activity_response["results"]
+ self.maxDiff = None
+ assert activity == expected
+
@patch("posthog.api.cohort.report_user_action")
@patch("posthog.tasks.calculate_cohort.calculate_cohort_ch.delay", side_effect=calculate_cohort_ch)
@patch("posthog.models.cohort.util.sync_execute", side_effect=sync_execute)
@@ -281,6 +307,96 @@ def test_cohort_list(self):
self.assertEqual(response["results"][0]["name"], "whatever")
self.assertEqual(response["results"][0]["created_by"]["id"], self.user.id)
+ def test_cohort_activity_log(self):
+ self.team.app_urls = ["http://somewebsite.com"]
+ self.team.save()
+ Person.objects.create(team=self.team, properties={"prop": 5})
+ Person.objects.create(team=self.team, properties={"prop": 6})
+
+ self.client.post(
+ f"/api/projects/{self.team.id}/cohorts",
+ data={"name": "whatever", "groups": [{"properties": {"prop": 5}}]},
+ )
+
+ cohort = Cohort.objects.filter(team=self.team).last()
+ assert cohort is not None
+
+ self.assert_cohort_activity(
+ cohort_id=cohort.pk,
+ expected=[
+ {
+ "user": {"first_name": "", "email": "user1@posthog.com"},
+ "activity": "created",
+ "scope": "Cohort",
+ "item_id": str(cohort.pk),
+ "detail": {"changes": None, "trigger": None, "name": "whatever", "short_id": None, "type": None},
+ "created_at": mock.ANY,
+ }
+ ],
+ )
+
+ self.client.patch(
+ f"/api/projects/{self.team.id}/cohorts/{cohort.pk}",
+ data={"name": "woohoo", "groups": [{"properties": {"prop": 6}}]},
+ )
+ cohort.refresh_from_db()
+ assert cohort.name == "woohoo"
+
+ self.assert_cohort_activity(
+ cohort_id=cohort.pk,
+ expected=[
+ {
+ "user": {"first_name": "", "email": "user1@posthog.com"},
+ "activity": "updated",
+ "scope": "Cohort",
+ "item_id": str(cohort.pk),
+ "detail": {
+ "changes": [
+ {
+ "type": "Cohort",
+ "action": "changed",
+ "field": "name",
+ "before": "whatever",
+ "after": "woohoo",
+ },
+ {
+ "type": "Cohort",
+ "action": "changed",
+ "field": "groups",
+ "before": [
+ {
+ "days": None,
+ "count": None,
+ "label": None,
+ "end_date": None,
+ "event_id": None,
+ "action_id": None,
+ "properties": [{"key": "prop", "type": "person", "value": 5}],
+ "start_date": None,
+ "count_operator": None,
+ }
+ ],
+ "after": [{"properties": [{"key": "prop", "type": "person", "value": 6}]}],
+ },
+ ],
+ "trigger": None,
+ "name": "woohoo",
+ "short_id": None,
+ "type": None,
+ },
+ "created_at": mock.ANY,
+ },
+ {
+ "user": {"first_name": "", "email": "user1@posthog.com"},
+ "activity": "created",
+ "scope": "Cohort",
+ "item_id": str(cohort.pk),
+ "detail": {"changes": None, "trigger": None, "name": "whatever", "short_id": None, "type": None},
+ "created_at": mock.ANY,
+ },
+ ],
+ )
+
def test_csv_export_new(self):
# Test 100s of distinct_ids, we only want ~10
Person.objects.create(
diff --git a/posthog/models/activity_logging/activity_log.py b/posthog/models/activity_logging/activity_log.py
index 141130ea4f80e..c05b0cac48bbd 100644
--- a/posthog/models/activity_logging/activity_log.py
+++ b/posthog/models/activity_logging/activity_log.py
@@ -18,6 +18,7 @@
logger = structlog.get_logger(__name__)
ActivityScope = Literal[
+ "Cohort",
"FeatureFlag",
"Person",
"Insight",
@@ -133,6 +134,14 @@ class Meta:
field_exclusions: dict[ActivityScope, list[str]] = {
+ "Cohort": [
+ "version",
+ "pending_version",
+ "count",
+ "is_calculating",
+ "last_calculation",
+ "errors_calculating",
+ ],
"Notebook": [
"text_content",
],