diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 14671b7a808e1..c958ac2a03ee8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,6 +5,7 @@ import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' import { apiStatusLogic } from 'lib/logic/apiStatusLogic' import { objectClean, toParams } from 'lib/utils' import posthog from 'posthog-js' +import { NewFeatureForm } from 'scenes/feature-management/featureManagementEditLogic' import { RecordingComment } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' import { SavedSessionRecordingPlaylistsResult } from 'scenes/session-recordings/saved-playlists/savedSessionRecordingPlaylistsLogic' @@ -57,6 +58,7 @@ import { FeatureFlagAssociatedRoleType, FeatureFlagStatusResponse, FeatureFlagType, + FeatureType, Group, GroupListParams, HogFunctionIconResponse, @@ -632,6 +634,15 @@ class ApiRequest { return this.annotations(teamId).addPathComponent(id) } + // # Feature managment + public features(teamId?: TeamType['id']): ApiRequest { + return this.projectsDetail(teamId).addPathComponent('features') + } + + public feature(id: FeatureType['id'], teamId?: TeamType['id']): ApiRequest { + return this.features(teamId).addPathComponent(id) + } + // # Feature flags public featureFlags(teamId?: TeamType['id']): ApiRequest { return this.projectsDetail(teamId).addPathComponent('feature_flags') @@ -1039,6 +1050,29 @@ const api = { }, }, + features: { + async list( + teamId: TeamType['id'] = ApiConfig.getCurrentTeamId() + ): Promise> { + return await new ApiRequest().features(teamId).get() + }, + async get(id: FeatureType['id'], teamId: TeamType['id'] = ApiConfig.getCurrentTeamId()): Promise { + return await new ApiRequest().feature(id, teamId).get() + }, + async create( + feature: NewFeatureForm, + teamId: TeamType['id'] = ApiConfig.getCurrentTeamId() + ): Promise { + return await new ApiRequest().features(teamId).create({ data: feature }) + }, + async update( + feature: FeatureType, + teamId: TeamType['id'] = ApiConfig.getCurrentTeamId() + ): Promise { + return await new ApiRequest().feature(feature.id, teamId).update({ data: feature }) + }, + }, + featureFlags: { async get(id: FeatureFlagType['id']): Promise { return await new ApiRequest().featureFlag(id).get() diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index e2e63409fc5fe..abfcf0dadb6f0 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -32,7 +32,8 @@ export const appScenes: Record any> = { [Scene.ExperimentsSavedMetric]: () => import('./experiments/SavedMetrics/SavedMetric'), [Scene.Experiment]: () => import('./experiments/Experiment'), [Scene.FeatureFlags]: () => import('./feature-flags/FeatureFlags'), - [Scene.FeatureManagement]: () => import('./feature-flags/FeatureManagement'), + [Scene.FeatureManagement]: () => import('./feature-management/FeatureManagement'), + [Scene.FeatureManagementNew]: () => import('./feature-management/FeatureManagementEdit'), [Scene.FeatureFlag]: () => import('./feature-flags/FeatureFlag'), [Scene.EarlyAccessFeatures]: () => import('./early-access-features/EarlyAccessFeatures'), [Scene.EarlyAccessFeature]: () => import('./early-access-features/EarlyAccessFeature'), diff --git a/frontend/src/scenes/feature-flags/FeatureManagement.tsx b/frontend/src/scenes/feature-flags/FeatureManagement.tsx deleted file mode 100644 index d2d67c7286886..0000000000000 --- a/frontend/src/scenes/feature-flags/FeatureManagement.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useActions, useValues } from 'kea' -import { LemonButton } from 'lib/lemon-ui/LemonButton' -import { SceneExport } from 'scenes/sceneTypes' - -import { FeatureManagementDetail } from './FeatureManagementDetail' -import { featureManagementLogic } from './featureManagementLogic' - -export const scene: SceneExport = { - component: FeatureManagement, - logic: featureManagementLogic, -} - -export function FeatureManagement(): JSX.Element { - const { activeFeatureId, features } = useValues(featureManagementLogic) - const { setActiveFeatureId } = useActions(featureManagementLogic) - - return ( -
-
    - {features.results.map((feature) => ( -
  • - setActiveFeatureId(feature.id)} - size="small" - fullWidth - active={activeFeatureId === feature.id} - > - {feature.name} - -
  • - ))} -
-
- -
-
- ) -} diff --git a/frontend/src/scenes/feature-flags/featureManagementDetailLogic.ts b/frontend/src/scenes/feature-flags/featureManagementDetailLogic.ts deleted file mode 100644 index 7389893c32860..0000000000000 --- a/frontend/src/scenes/feature-flags/featureManagementDetailLogic.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { connect, kea, path, props } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -import type { featureManagementDetailLogicType } from './featureManagementDetailLogicType' -import { featureManagementLogic } from './featureManagementLogic' - -export const featureManagementDetailLogic = kea([ - props({}), - path(['scenes', 'features', 'featureManagementDetailLogic']), - connect({ - values: [teamLogic, ['currentTeamId'], featureManagementLogic, ['activeFeatureId', 'activeFeature']], - }), -]) diff --git a/frontend/src/scenes/feature-management/FeatureManagement.tsx b/frontend/src/scenes/feature-management/FeatureManagement.tsx new file mode 100644 index 0000000000000..ee75ca58b0bb5 --- /dev/null +++ b/frontend/src/scenes/feature-management/FeatureManagement.tsx @@ -0,0 +1,32 @@ +import { useValues } from 'kea' +import { SceneExport } from 'scenes/sceneTypes' + +import { FeatureManagementDetail } from './FeatureManagementDetail' +import { FeatureManagementEmptyState } from './FeatureManagementEmptyState' +import { FeatureManagementList } from './FeatureManagementList' +import { featureManagementLogic } from './featureManagementLogic' + +export const scene: SceneExport = { + component: FeatureManagement, + logic: featureManagementLogic, +} + +export function FeatureManagement(): JSX.Element { + const { features } = useValues(featureManagementLogic) + + if (features?.results.length === 0) { + return + } + + return ( +
+
+ +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/scenes/feature-flags/FeatureManagementDetail.tsx b/frontend/src/scenes/feature-management/FeatureManagementDetail.tsx similarity index 76% rename from frontend/src/scenes/feature-flags/FeatureManagementDetail.tsx rename to frontend/src/scenes/feature-management/FeatureManagementDetail.tsx index 73c5776094983..3d33f8538cbd0 100644 --- a/frontend/src/scenes/feature-flags/FeatureManagementDetail.tsx +++ b/frontend/src/scenes/feature-management/FeatureManagementDetail.tsx @@ -1,8 +1,31 @@ -import { LemonSkeleton } from '@posthog/lemon-ui' -import { useValues } from 'kea' +import { LemonButton, LemonDivider, LemonSkeleton } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { More } from 'lib/lemon-ui/LemonButton/More' import { featureManagementDetailLogic } from './featureManagementDetailLogic' +function Header(): JSX.Element { + const { activeFeature } = useValues(featureManagementDetailLogic) + const { deleteFeature } = useActions(featureManagementDetailLogic) + + return ( +
+
{activeFeature?.name}
+ + Edit + + deleteFeature(activeFeature)}> + Delete feature + + + } + /> +
+ ) +} + function Metadata(): JSX.Element { return (
@@ -70,11 +93,9 @@ function Permissions(): JSX.Element { } export function FeatureManagementDetail(): JSX.Element { - const { activeFeature } = useValues(featureManagementDetailLogic) - return (
-
{activeFeature?.name}
+
diff --git a/frontend/src/scenes/feature-management/FeatureManagementEdit.tsx b/frontend/src/scenes/feature-management/FeatureManagementEdit.tsx new file mode 100644 index 0000000000000..a6379a1a3cd5b --- /dev/null +++ b/frontend/src/scenes/feature-management/FeatureManagementEdit.tsx @@ -0,0 +1,105 @@ +import { LemonButton, LemonDivider, LemonInput, LemonTextArea } from '@posthog/lemon-ui' +import { useValues } from 'kea' +import { Form } from 'kea-forms' +import { router } from 'kea-router' +import { PageHeader } from 'lib/components/PageHeader' +import { LemonField } from 'lib/lemon-ui/LemonField' +import { SceneExport } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { featureManagementEditLogic } from './featureManagementEditLogic' + +export const scene: SceneExport = { + component: FeatureManagementEdit, + logic: featureManagementEditLogic, + paramsToProps: ({ params: { id } }): (typeof featureManagementEditLogic)['props'] => ({ + id: id && id !== 'new' ? id : 'new', + }), +} + +function FeatureManagementEdit(): JSX.Element { + const { props } = useValues(featureManagementEditLogic) + + return ( +
+ + router.actions.push(urls.featureManagement())} + > + Cancel + + + Save + +
+ } + /> +
+
+ + + + + + + + + This will be used to monitor feature usage. Feature keys must be unique to other features and + feature flags. + + + + + +
+
+ + +
+ router.actions.push(urls.featureManagement())} + > + Cancel + + + Save + +
+ + ) +} diff --git a/frontend/src/scenes/feature-management/FeatureManagementEmptyState.tsx b/frontend/src/scenes/feature-management/FeatureManagementEmptyState.tsx new file mode 100644 index 0000000000000..59cb231394c84 --- /dev/null +++ b/frontend/src/scenes/feature-management/FeatureManagementEmptyState.tsx @@ -0,0 +1,25 @@ +import { IconPlusSmall } from '@posthog/icons' +import { BuilderHog3 } from 'lib/components/hedgehogs' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { Link } from 'lib/lemon-ui/Link' +import { urls } from 'scenes/urls' + +export function FeatureManagementEmptyState(): JSX.Element { + return ( +
+
+ +
+

No features created yet

+

Start your first big feature rollout today.

+ +
+ + }> + New feature + + +
+
+ ) +} diff --git a/frontend/src/scenes/feature-management/FeatureManagementList.tsx b/frontend/src/scenes/feature-management/FeatureManagementList.tsx new file mode 100644 index 0000000000000..1605af2163ae9 --- /dev/null +++ b/frontend/src/scenes/feature-management/FeatureManagementList.tsx @@ -0,0 +1,50 @@ +import { IconPlusSmall } from '@posthog/icons' +import { LemonSkeleton, Link } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { urls } from 'scenes/urls' + +import { featureManagementLogic } from './featureManagementLogic' + +export function FeatureManagementList(): JSX.Element { + const { activeFeatureId, features, featuresLoading } = useValues(featureManagementLogic) + const { setActiveFeatureId } = useActions(featureManagementLogic) + + const header = ( +
+

Features

+ + }> + New feature + + +
+ ) + + return ( +
+ {header} +
+ {featuresLoading && ( + <> + + + + + )} + {features?.results.map((feature) => ( +
+ setActiveFeatureId(feature.id)} + size="small" + fullWidth + active={activeFeatureId === feature.id} + > + {feature.name} + +
+ ))} +
+
+ ) +} diff --git a/frontend/src/scenes/feature-management/featureManagementDetailLogic.ts b/frontend/src/scenes/feature-management/featureManagementDetailLogic.ts new file mode 100644 index 0000000000000..e2f48408a91b2 --- /dev/null +++ b/frontend/src/scenes/feature-management/featureManagementDetailLogic.ts @@ -0,0 +1,42 @@ +import { actions, connect, kea, listeners, path, props } from 'kea' +import { router } from 'kea-router' +import { deleteWithUndo } from 'lib/utils/deleteWithUndo' +import { projectLogic } from 'scenes/projectLogic' +import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' + +import { FeatureType } from '~/types' + +import type { featureManagementDetailLogicType } from './featureManagementDetailLogicType' +import { featureManagementLogic } from './featureManagementLogic' + +export const featureManagementDetailLogic = kea([ + props({}), + path(['scenes', 'features', 'featureManagementDetailLogic']), + connect({ + values: [ + teamLogic, + ['currentTeamId'], + projectLogic, + ['currentProjectId'], + featureManagementLogic, + ['activeFeatureId', 'activeFeature'], + ], + actions: [featureManagementLogic, ['loadFeatures']], + }), + actions({ + deleteFeature: (feature: FeatureType) => ({ feature }), + }), + listeners(({ actions, values }) => ({ + deleteFeature: async ({ feature }) => { + await deleteWithUndo({ + endpoint: `projects/${values.currentProjectId}/features`, + object: { id: feature.id, name: feature.name }, + callback: () => { + actions.loadFeatures() + router.actions.push(urls.featureManagement()) + }, + }) + }, + })), +]) diff --git a/frontend/src/scenes/feature-management/featureManagementEditLogic.ts b/frontend/src/scenes/feature-management/featureManagementEditLogic.ts new file mode 100644 index 0000000000000..b5ddc63fe6d27 --- /dev/null +++ b/frontend/src/scenes/feature-management/featureManagementEditLogic.ts @@ -0,0 +1,112 @@ +import { lemonToast } from '@posthog/lemon-ui' +import { connect, kea, listeners, path, props, reducers, selectors } from 'kea' +import { forms } from 'kea-forms' +import { loaders } from 'kea-loaders' +import { router } from 'kea-router' +import api from 'lib/api' +import { Scene } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { Breadcrumb, FeatureType } from '~/types' + +import type { featureManagementEditLogicType } from './featureManagementEditLogicType' +import { featureManagementLogic } from './featureManagementLogic' + +export interface FeatureLogicProps { + /** Either a UUID or "new". */ + id: string +} + +export type NewFeatureForm = Pick + +const NEW_FEATURE: NewFeatureForm = { + key: '', + name: '', + description: '', +} + +export const featureManagementEditLogic = kea([ + props({} as FeatureLogicProps), + path(['scenes', 'features', 'featureManagementNewLogic']), + connect({ + actions: [featureManagementLogic, ['loadFeatures']], + }), + loaders(({ props, actions }) => ({ + feature: { + saveFeature: async (updatedFeature: NewFeatureForm | FeatureType) => { + let feature + if (props.id === 'new') { + feature = await api.features.create(updatedFeature) + } else { + feature = await api.features.update(updatedFeature as FeatureType) + } + + // Reset the form after creation + actions.resetFeature() + return feature + }, + }, + })), + forms(({ actions }) => ({ + feature: { + defaults: { ...NEW_FEATURE }, + // sync validation, will be shown as errors in the form + errors: ({ name }) => { + if (!name) { + return { name: 'Name is required' } + } + return {} + }, + submit: (feature) => { + actions.saveFeature(feature) + }, + }, + })), + reducers({ + feature: [ + NEW_FEATURE, + { + setFeatureValue: (state, { name, value }) => { + const updatedField = typeof name === 'object' ? name[0] : name + const feature = { ...state, [updatedField]: value } + + if (updatedField === 'name') { + // Set the key to a slugified version of the name + feature.key = feature.name.toLowerCase().replace(/[^a-z0-9_]/g, '-') + } + + return feature + }, + }, + ], + }), + selectors({ + props: [() => [(_, props) => props], (props) => props], + breadcrumbs: [ + () => [], + (): Breadcrumb[] => { + const breadcrumbs: Breadcrumb[] = [ + { + key: Scene.FeatureManagement, + name: 'Features', + path: urls.featureManagement(), + }, + { + key: Scene.FeatureManagementNew, + name: 'New feature', + path: urls.featureManagementNew(), + }, + ] + + return breadcrumbs + }, + ], + }), + listeners(({ actions }) => ({ + saveFeatureSuccess: ({ feature }) => { + lemonToast.success(`Feature ${feature.name} saved`) + actions.loadFeatures() + feature.id && router.actions.replace(urls.featureManagement(feature.id)) + }, + })), +]) diff --git a/frontend/src/scenes/feature-flags/featureManagementLogic.ts b/frontend/src/scenes/feature-management/featureManagementLogic.ts similarity index 73% rename from frontend/src/scenes/feature-flags/featureManagementLogic.ts rename to frontend/src/scenes/feature-management/featureManagementLogic.ts index b6de6f8b79bcf..494d2abc14774 100644 --- a/frontend/src/scenes/feature-flags/featureManagementLogic.ts +++ b/frontend/src/scenes/feature-management/featureManagementLogic.ts @@ -1,57 +1,51 @@ import { actions, afterMount, connect, kea, listeners, path, props, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { actionToUrl, urlToAction } from 'kea-router' -import api from 'lib/api' +import api, { CountedPaginatedResponse } from 'lib/api' +import { projectLogic } from 'scenes/projectLogic' import { Scene } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' -import { Breadcrumb, Feature } from '~/types' +import { Breadcrumb, FeatureType } from '~/types' import type { featureManagementLogicType } from './featureManagementLogicType' export interface FeatureManagementLogicProps { - id?: Feature['id'] -} -export interface FeaturesResult { - results: Feature[] - count: number - next?: string | null - previous?: string | null + id?: FeatureType['id'] } +export type FeaturesResult = CountedPaginatedResponse + export const featureManagementLogic = kea([ props({} as FeatureManagementLogicProps), path(['scenes', 'features', 'featureManagementLogic']), connect({ - values: [teamLogic, ['currentTeamId']], + values: [teamLogic, ['currentTeamId'], projectLogic, ['currentProjectId']], }), actions({ - setActiveFeatureId: (activeFeatureId: Feature['id']) => ({ activeFeatureId }), + setActiveFeatureId: (activeFeatureId: FeatureType['id']) => ({ activeFeatureId }), }), reducers({ activeFeatureId: [ - null as Feature['id'] | null, + null as FeatureType['id'] | null, { setActiveFeatureId: (_, { activeFeatureId }) => activeFeatureId, }, ], }), - loaders(({ values }) => ({ + loaders({ features: [ { results: [], count: 0, offset: 0 } as FeaturesResult, { - loadFeatures: async () => { - const response = await api.get(`api/projects/${values.currentTeamId}/features`) - return response.data as FeaturesResult - }, + loadFeatures: () => api.features.list(), }, ], - })), + }), selectors({ activeFeature: [ (s) => [s.activeFeatureId, s.features], - (activeFeatureId, features) => features.results.find((feature) => feature.id === activeFeatureId) || null, + (activeFeatureId, features) => features?.results.find((feature) => feature.id === activeFeatureId) || null, ], breadcrumbs: [ (s) => [s.activeFeatureId, s.activeFeature], @@ -90,8 +84,8 @@ export const featureManagementLogic = kea([ }), urlToAction(({ actions, values }) => ({ '/features/:id': ({ id }) => { - if (id && String(values.activeFeatureId) !== id) { - actions.setActiveFeatureId(Number(id)) + if (id && String(values.activeFeatureId) !== id && id !== 'new') { + actions.setActiveFeatureId(id) } }, })), diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 3b9f213299686..714f166caa193 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -40,6 +40,7 @@ export enum Scene { ExperimentsSavedMetric = 'ExperimentsSavedMetric', Experiment = 'Experiment', FeatureManagement = 'FeatureManagement', + FeatureManagementNew = 'FeatureManagementNew', FeatureFlags = 'FeatureFlags', FeatureFlag = 'FeatureFlag', EarlyAccessFeatures = 'EarlyAccessFeatures', diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index c93a10595165c..8d070fb3222a6 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -232,6 +232,11 @@ export const sceneConfigurations: Record = { name: 'Features', defaultDocsPath: '/docs/feature-flags', }, + [Scene.FeatureManagementNew]: { + projectBased: true, + name: 'Features', + defaultDocsPath: '/docs/feature-flags', + }, [Scene.Surveys]: { projectBased: true, name: 'Surveys', @@ -591,6 +596,7 @@ export const routes: Record = { [urls.sqlEditor()]: Scene.SQLEditor, [urls.featureFlags()]: Scene.FeatureFlags, [urls.featureFlag(':id')]: Scene.FeatureFlag, + [urls.featureManagementNew()]: Scene.FeatureManagementNew, [urls.featureManagement()]: Scene.FeatureManagement, [urls.featureManagement(':id')]: Scene.FeatureManagement, [urls.annotations()]: Scene.DataManagement, diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 38647a1e9389b..e46720d49d180 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -162,6 +162,7 @@ export const urls = { featureFlags: (tab?: string): string => `/feature_flags${tab ? `?tab=${tab}` : ''}`, featureFlag: (id: string | number): string => `/feature_flags/${id}`, featureManagement: (id?: string | number): string => `/features${id ? `/${id}` : ''}`, + featureManagementNew: (): string => `/features/new`, earlyAccessFeatures: (): string => '/early_access_features', /** @param id A UUID or 'new'. ':id' for routing. */ earlyAccessFeature: (id: string): string => `/early_access_features/${id}`, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ce3b9684d5a2f..578cdcafc5eec 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3022,9 +3022,16 @@ export interface CombinedFeatureFlagAndValueType { value: boolean | string } -export interface Feature { - id: number | null +export interface FeatureType { + id: string + key: string name: string + description: string + primary_early_access_feature_id: string + archived: boolean + deleted: boolean + created_at: string | null + created_by: UserBasicType | null } export enum EarlyAccessFeatureStage { diff --git a/posthog/api/feature.py b/posthog/api/feature.py index f7ea04c41b8c6..7b3028df174fa 100644 --- a/posthog/api/feature.py +++ b/posthog/api/feature.py @@ -2,9 +2,9 @@ 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.models import Feature, EarlyAccessFeature, FeatureAlertConfiguration from posthog.api.alert import AlertSerializer -from posthog.api.early_access_feature import EarlyAccessFeatureSerializer +from posthog.api.early_access_feature import EarlyAccessFeatureSerializer, EarlyAccessFeatureSerializerCreateOnly from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.forbid_destroy_model import ForbidDestroyModel from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin @@ -23,6 +23,7 @@ class Meta: model = Feature fields = [ "id", + "key", "name", "description", "primary_early_access_feature_id", @@ -37,8 +38,30 @@ def create(self, validated_data): 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) + + early_access_feature_id = validated_data.get("primary_early_access_feature_id", None) + + # Attempt to get the early access feature if one was provided, just to ensure it exists + if early_access_feature_id: + early_access_feature = EarlyAccessFeature.objects.get(pk=early_access_feature_id) + + # Attempt to create a primary early access feature (and feature flag) for this feature if one was not provided + if not early_access_feature_id: + early_access_feature_serializer = EarlyAccessFeatureSerializerCreateOnly( + data={ + "name": validated_data["name"], + "description": validated_data["description"], + "stage": EarlyAccessFeature.Stage.DRAFT, + }, + context=self.context, + ) + early_access_feature_serializer.is_valid(raise_exception=True) + early_access_feature = early_access_feature_serializer.save() + + validated_data["primary_early_access_feature_id"] = early_access_feature.id + feature: Feature = super().create(validated_data) + + return feature def get_primary_early_access_feature(self, feature: Feature): return EarlyAccessFeatureSerializer(feature.primary_early_access_feature, context=self.context).data @@ -51,7 +74,7 @@ class FeatureViewSet(TeamAndOrgViewSetMixin, AccessControlViewSetMixin, ForbidDe def safely_get_queryset(self, queryset) -> QuerySet: # Base queryset with team filtering - queryset = Feature.objects.filter(team_id=self.team_id) + queryset = Feature.objects.filter(team_id=self.team_id, deleted=False) if self.action == "primary_early_access_feature": queryset = queryset.select_related("primary_early_access_feature")