diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b555746f4750e..ce3b9684d5a2f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3900,6 +3900,7 @@ export type APIScopeObject = | 'event_definition' | 'experiment' | 'export' + | 'feature' | 'feature_flag' | 'group' | 'insight' diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py index 5e0cd8d81c1e5..7b3a3cf3f0802 100644 --- a/posthog/api/__init__.py +++ b/posthog/api/__init__.py @@ -31,6 +31,7 @@ error_tracking, event_definition, exports, + feature, feature_flag, hog_function, hog_function_template, @@ -158,6 +159,12 @@ def register_grandfathered_environment_nested_viewset( "project_activity_log", ["project_id"], ) +projects_router.register( + r"features", + feature.FeatureViewSet, + "project_feature_management", + ["project_id"], +) project_feature_flags_router = projects_router.register( r"feature_flags", feature_flag.FeatureFlagViewSet, diff --git a/posthog/api/early_access_feature.py b/posthog/api/early_access_feature.py index a7cc6cd42558e..9fec782da47c3 100644 --- a/posthog/api/early_access_feature.py +++ b/posthog/api/early_access_feature.py @@ -30,14 +30,7 @@ class MinimalEarlyAccessFeatureSerializer(serializers.ModelSerializer): class Meta: model = EarlyAccessFeature - fields = [ - "id", - "name", - "description", - "stage", - "documentationUrl", - "flagKey", - ] + fields = ["id", "name", "description", "stage", "documentationUrl", "flagKey"] read_only_fields = fields diff --git a/posthog/api/feature.py b/posthog/api/feature.py new file mode 100644 index 0000000000000..f7ea04c41b8c6 --- /dev/null +++ b/posthog/api/feature.py @@ -0,0 +1,81 @@ +from rest_framework import viewsets, serializers +from rest_framework.response import Response +from rest_framework.decorators import action +from django.db.models import QuerySet +from posthog.models import Feature, FeatureAlertConfiguration +from posthog.api.alert import AlertSerializer +from posthog.api.early_access_feature import EarlyAccessFeatureSerializer +from posthog.api.routing import TeamAndOrgViewSetMixin +from posthog.api.forbid_destroy_model import ForbidDestroyModel +from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin + + +class FeatureAlertConfigurationSerializer(serializers.ModelSerializer): + alert_configuration = AlertSerializer() + + class Meta: + model = FeatureAlertConfiguration + fields = ["alert_configuration", "feature_insight_type"] + + +class FeatureSerializer(serializers.ModelSerializer): + class Meta: + model = Feature + fields = [ + "id", + "name", + "description", + "primary_early_access_feature_id", + "created_at", + "created_by", + "archived", + "deleted", + ] + + def create(self, validated_data): + request = self.context["request"] + + validated_data["team_id"] = self.context["team_id"] + validated_data["created_by"] = request.user + validated_data["primary_early_access_feature_id"] = request.data.get("primary_early_access_feature_id") + return super().create(validated_data) + + def get_primary_early_access_feature(self, feature: Feature): + return EarlyAccessFeatureSerializer(feature.primary_early_access_feature, context=self.context).data + + +class FeatureViewSet(TeamAndOrgViewSetMixin, AccessControlViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet): + scope_object = "feature" + queryset = Feature.objects.all() + serializer_class = FeatureSerializer + + def safely_get_queryset(self, queryset) -> QuerySet: + # Base queryset with team filtering + queryset = Feature.objects.filter(team_id=self.team_id) + + if self.action == "primary_early_access_feature": + queryset = queryset.select_related("primary_early_access_feature") + elif self.action == "alerts": + queryset = queryset.prefetch_related("alerts") + + return queryset + + @action(detail=True, methods=["get"]) + def primary_early_access_feature(self, request, pk=None, **kwargs): + """ + Get primary feature flag associated with a specific feature. + """ + feature = self.get_object() + primary_early_access_feature = FeatureSerializer().get_primary_early_access_feature(feature) + return Response(primary_early_access_feature) + + @action(detail=True, methods=["get"]) + def alerts(self, request, pk=None, **kwargs): + """ + Get all alerts associated with a specific feature. These alerts are used to track + success and failure metrics for the feature. + """ + feature = self.get_object() + alerts_for_feature = FeatureAlertConfiguration.objects.filter(feature=feature).all() + alerts = FeatureAlertConfigurationSerializer(alerts_for_feature, many=True).data + return Response(alerts) diff --git a/posthog/api/test/__snapshots__/test_api_docs.ambr b/posthog/api/test/__snapshots__/test_api_docs.ambr index 862d8507dbb75..0798a6ee9aba6 100644 --- a/posthog/api/test/__snapshots__/test_api_docs.ambr +++ b/posthog/api/test/__snapshots__/test_api_docs.ambr @@ -37,6 +37,7 @@ '/home/runner/work/posthog/posthog/posthog/api/exports.py: Warning [ExportedAssetViewSet > ExportedAssetSerializer]: unable to resolve type hint for function "filename". Consider using a type hint or @extend_schema_field. Defaulting to string.', '/home/runner/work/posthog/posthog/posthog/api/exports.py: Warning [ExportedAssetViewSet > ExportedAssetSerializer]: unable to resolve type hint for function "has_content". Consider using a type hint or @extend_schema_field. Defaulting to string.', '/home/runner/work/posthog/posthog/posthog/api/exports.py: Warning [ExportedAssetViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.exported_asset.ExportedAsset" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', + '/home/runner/work/posthog/posthog/posthog/api/feature.py: Warning [FeatureViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.feature.Feature" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', '/home/runner/work/posthog/posthog/posthog/api/feature_flag.py: Warning [FeatureFlagViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.feature_flag.feature_flag.FeatureFlag" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', '/home/runner/work/posthog/posthog/posthog/api/insight.py: Warning [EnterpriseInsightsViewSet > InsightSerializer]: unable to resolve type hint for function "get_cache_target_age". Consider using a type hint or @extend_schema_field. Defaulting to string.', '/home/runner/work/posthog/posthog/posthog/api/insight.py: Warning [EnterpriseInsightsViewSet > InsightSerializer]: unable to resolve type hint for function "get_columns". Consider using a type hint or @extend_schema_field. Defaulting to string.', diff --git a/posthog/api/test/test_feature.py b/posthog/api/test/test_feature.py new file mode 100644 index 0000000000000..e5dce27373d69 --- /dev/null +++ b/posthog/api/test/test_feature.py @@ -0,0 +1,114 @@ +from rest_framework import status + +from posthog.models import AlertConfiguration, EarlyAccessFeature, Feature, FeatureAlertConfiguration, Insight +from posthog.test.base import APIBaseTest + + +class TestFeatureAPI(APIBaseTest): + def setUp(self): + super().setUp() + self.early_access_feature = EarlyAccessFeature.objects.create( + team=self.team, + name="Test EAF", + description="Test Description", + ) + self.feature = Feature.objects.create( + team=self.team, + name="Test Feature", + description="Test Description", + primary_early_access_feature=self.early_access_feature, + ) + + def test_list_features(self): + response = self.client.get(f"/api/projects/{self.team.id}/features/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.json()["results"]), 1) + self.assertEqual(response.json()["results"][0]["name"], "Test Feature") + self.assertEqual( + response.json()["results"][0]["primary_early_access_feature_id"], f"{self.early_access_feature.id}" + ) + + # Ensure we are not returning joined data + with self.assertRaises(KeyError): + response.json()["results"][0]["alerts"] + with self.assertRaises(KeyError): + response.json()["results"][0]["primary_early_access_feature"] + + def test_retrieve_feature(self): + eaf = EarlyAccessFeature.objects.create( + team=self.team, + name="Test EAF", + description="Test Description", + ) + self.feature.primary_early_access_feature = eaf + + response = self.client.get(f"/api/projects/{self.team.id}/features/{self.feature.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["name"], "Test Feature") + self.assertEqual(response.json()["description"], "Test Description") + + # Ensure we are not returning joined data + with self.assertRaises(KeyError): + response.json()["results"][0]["alerts"] + with self.assertRaises(KeyError): + response.json()["results"][0]["primary_early_access_feature"] + + def test_create_feature(self): + response = self.client.post( + f"/api/projects/{self.team.id}/features/", + { + "name": "New Feature", + "team_id": self.team.id, + "description": "New Description", + "primary_early_access_feature_id": self.early_access_feature.id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json()["name"], "New Feature") + self.assertEqual(Feature.objects.count(), 2) + + def test_update_feature(self): + response = self.client.patch( + f"/api/projects/{self.team.id}/features/{self.feature.id}/", + { + "name": "Updated Feature", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["name"], "Updated Feature") + + def test_delete_not_allowed(self): + response = self.client.delete(f"/api/projects/{self.team.id}/features/{self.feature.id}/") + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_get_primary_early_access_feature(self): + eaf = EarlyAccessFeature.objects.create( + team=self.team, + name="Test Flag", + ) + self.feature.primary_early_access_feature = eaf + self.feature.save() + + response = self.client.get( + f"/api/projects/{self.team.id}/features/{self.feature.id}/primary_early_access_feature/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["name"], "Test Flag") + + def test_get_alerts(self): + insight = Insight.objects.create( + team=self.team, + filters={"events": [{"id": "$pageview", "type": "events", "name": "pageview"}]}, + ) + alert = AlertConfiguration.objects.create(team=self.team, name="Test Alert", insight=insight) + FeatureAlertConfiguration.objects.create( + team=self.team, + feature=self.feature, + alert_configuration=alert, + feature_insight_type="success_metric", + ) + + response = self.client.get(f"/api/projects/{self.team.id}/features/{self.feature.id}/alerts/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["feature_insight_type"], "success_metric") diff --git a/posthog/migrations/0540_feature_mgmt_tables.py b/posthog/migrations/0540_feature_mgmt_tables.py new file mode 100644 index 0000000000000..d9253668b3165 --- /dev/null +++ b/posthog/migrations/0540_feature_mgmt_tables.py @@ -0,0 +1,87 @@ +# Generated by Django 3.2.18 on 2023-06-22 11:08 + +from django.db import migrations, models +from django.conf import settings + +import posthog.models.utils + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0539_user_role_at_organization"), + ] + + operations = [ + migrations.CreateModel( + name="Feature", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("key", models.CharField(max_length=400, blank=False)), + ("team", models.ForeignKey(on_delete=models.deletion.CASCADE, to="posthog.team")), + ("name", models.CharField(max_length=400, blank=False)), + ("description", models.TextField(default="", blank=True)), + ( + "primary_early_access_feature", + models.ForeignKey(on_delete=models.deletion.RESTRICT, to="posthog.earlyaccessfeature"), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, null=True, on_delete=models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ("archived", models.BooleanField(default=False)), + ("deleted", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="FeatureAlertConfiguration", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "team", + models.ForeignKey(on_delete=models.deletion.CASCADE, to="posthog.Team"), + ), + ( + "feature", + models.ForeignKey(on_delete=models.deletion.CASCADE, to="posthog.Feature"), + ), + ( + "alert_configuration", + models.ForeignKey(on_delete=models.deletion.CASCADE, to="posthog.AlertConfiguration"), + ), + ( + "feature_insight_type", + models.CharField( + max_length=32, + choices=[("success_metric", "Success Metric"), ("faiure_metric", "Failure Metric")], + ), + ), + ], + ), + migrations.AddField( + model_name="Feature", + name="alerts", + field=models.ManyToManyField( + to="posthog.AlertConfiguration", + through="posthog.FeatureAlertConfiguration", + ), + ), + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index b182af0cabf6e..0cdea62674914 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -0539_user_role_at_organization +0540_feature_mgmt_tables \ No newline at end of file diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index b3664854fb40e..9517ec640887f 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -43,6 +43,7 @@ from .event_property import EventProperty from .experiment import Experiment from .exported_asset import ExportedAsset +from .feature import Feature, FeatureAlertConfiguration from .feature_flag import FeatureFlag from .feedback.survey import Survey from .filters import Filter, RetentionFilter @@ -119,6 +120,8 @@ "EventProperty", "Experiment", "ExportedAsset", + "Feature", + "FeatureAlertConfiguration", "FeatureFlag", "Filter", "Group", diff --git a/posthog/models/feature.py b/posthog/models/feature.py new file mode 100644 index 0000000000000..bd222535882ce --- /dev/null +++ b/posthog/models/feature.py @@ -0,0 +1,28 @@ +from django.db import models + +from posthog.models.utils import UUIDModel, CreatedMetaFields + + +class Feature(CreatedMetaFields, UUIDModel): + # Key must be unique per team across all features and all feature flags, + # as the early access feature will create a flag with this same key. + key = models.CharField(max_length=400, blank=False) + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE) + name = models.CharField(max_length=400, blank=False) + description = models.TextField(default="", blank=True) + primary_early_access_feature = models.ForeignKey( + "posthog.EarlyAccessFeature", on_delete=models.RESTRICT, blank=False + ) + alerts = models.ManyToManyField("posthog.AlertConfiguration", through="posthog.FeatureAlertConfiguration") + archived = models.BooleanField(default=False) + deleted = models.BooleanField(default=False) + + +class FeatureAlertConfiguration(UUIDModel): + team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE) + feature = models.ForeignKey("posthog.Feature", on_delete=models.CASCADE) + alert_configuration = models.ForeignKey("posthog.AlertConfiguration", on_delete=models.CASCADE) + feature_insight_type = models.CharField( + max_length=32, + choices=[("success_metric", "Success Metric"), ("faiure_metric", "Failure Metric")], + ) diff --git a/posthog/models/scopes.py b/posthog/models/scopes.py index 2bd0d48b405e5..6f7e088f3ff3c 100644 --- a/posthog/models/scopes.py +++ b/posthog/models/scopes.py @@ -25,6 +25,7 @@ "event_definition", "experiment", "export", + "feature", "feature_flag", "group", "insight",