From 5ac05e2cbc12b9469d836bdb152eb2c2bf1f3788 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 4 Feb 2026 18:21:16 +0100 Subject: [PATCH 01/21] feat: calculate-has-metadata-required-based-on-all-entities --- .../metadata/AddMetadataToEntity.tsx | 22 ++++++++++++++-- .../components/modals/create-feature/index.js | 25 ++++++++++++------- .../create-feature/tabs/FeatureSettings.tsx | 2 +- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/frontend/web/components/metadata/AddMetadataToEntity.tsx b/frontend/web/components/metadata/AddMetadataToEntity.tsx index f460b07af5d4..a1f484906217 100644 --- a/frontend/web/components/metadata/AddMetadataToEntity.tsx +++ b/frontend/web/components/metadata/AddMetadataToEntity.tsx @@ -104,6 +104,24 @@ const AddMetadataToEntity: FC = ({ const [metadataChanged, setMetadataChanged] = useState(false) + // Compute hasMetadataRequired reactively when state changes + useEffect(() => { + if (!metadataFieldsAssociatedtoEntity) return + + const totalRequired = metadataFieldsAssociatedtoEntity.filter( + (m) => m.isRequiredFor, + ).length + const totalFilledRequired = metadataFieldsAssociatedtoEntity.filter( + (m) => m.field_value && m.field_value !== '' && m.isRequiredFor, + ).length + + // hasMetadataRequired = true means "there are unfilled required fields" + setHasMetadataRequired?.( + totalRequired > 0 && totalFilledRequired < totalRequired, + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [metadataFieldsAssociatedtoEntity]) + const mergeMetadataEntityWithMetadataField = ( metadata: Metadata[], // Metadata array metadataField: CustomMetadataField[], // Custom metadata field array @@ -158,11 +176,11 @@ const AddMetadataToEntity: FC = ({ }) // Determine if isRequiredFor should be true or false based on is_required_for array const isRequiredFor = !!matchingItem?.is_required_for.length - setHasMetadataRequired?.(isRequiredFor) + // Return the metadata field with additional metadata model field information including isRequiredFor return { ...meta, - isRequiredFor: isRequiredFor || false, + isRequiredFor, metadataModelFieldId: matchingItem ? matchingItem.id : null, } }) diff --git a/frontend/web/components/modals/create-feature/index.js b/frontend/web/components/modals/create-feature/index.js index ea838aef32c5..53bce83bd7e6 100644 --- a/frontend/web/components/modals/create-feature/index.js +++ b/frontend/web/components/modals/create-feature/index.js @@ -829,9 +829,6 @@ const Index = class extends Component { > {({ permission: projectAdmin }) => { this.state.skipSaveProjectFeature = !createFeature - const _hasMetadataRequired = - this.state.hasMetadataRequired && - !projectFlag.metadata?.length return (
@@ -1706,11 +1703,15 @@ const Index = class extends Component { }} onHasMetadataRequiredChange={( hasMetadataRequired, - ) => + ) => { + console.log( + 'hasMetadataRequired', + hasMetadataRequired, + ) this.setState({ hasMetadataRequired, }) - } + }} /> {isSaving @@ -1820,11 +1821,15 @@ const Index = class extends Component { } onHasMetadataRequiredChange={( hasMetadataRequired, - ) => + ) => { + console.log( + 'hasMetadataRequired', + hasMetadataRequired, + ) this.setState({ hasMetadataRequired, }) - } + }} featureError={ this.parseError(error).featureError } @@ -1842,7 +1847,9 @@ const Index = class extends Component { featureLimitPercentage={ this.state.featureLimitAlert.percentage } - hasMetadataRequired={_hasMetadataRequired} + hasMetadataRequired={ + this.state.hasMetadataRequired + } />
)} diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx index 456f0e068fd4..28d4b1ffa273 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx @@ -75,7 +75,7 @@ const FeatureSettings: FC = ({ )} {metadataEnable && featureContentType?.id && !identity && ( <> - + {/* */} Date: Wed, 4 Feb 2026 18:21:16 +0100 Subject: [PATCH 02/21] fix: calculate-has-metadata-required-based-on-all-entities --- .../metadata/AddMetadataToEntity.tsx | 20 +++++++++++++++++-- .../components/modals/create-feature/index.js | 17 +++++++++------- .../create-feature/tabs/FeatureSettings.tsx | 2 +- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/frontend/web/components/metadata/AddMetadataToEntity.tsx b/frontend/web/components/metadata/AddMetadataToEntity.tsx index f460b07af5d4..b62262fe3572 100644 --- a/frontend/web/components/metadata/AddMetadataToEntity.tsx +++ b/frontend/web/components/metadata/AddMetadataToEntity.tsx @@ -104,6 +104,22 @@ const AddMetadataToEntity: FC = ({ const [metadataChanged, setMetadataChanged] = useState(false) + useEffect(() => { + if (!metadataFieldsAssociatedtoEntity) return + + const totalRequired = metadataFieldsAssociatedtoEntity.filter( + (m) => m.isRequiredFor, + ).length + const totalFilledRequired = metadataFieldsAssociatedtoEntity.filter( + (m) => m.field_value && m.field_value !== '' && m.isRequiredFor, + ).length + + setHasMetadataRequired?.( + totalRequired > 0 && totalFilledRequired < totalRequired, + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [metadataFieldsAssociatedtoEntity]) + const mergeMetadataEntityWithMetadataField = ( metadata: Metadata[], // Metadata array metadataField: CustomMetadataField[], // Custom metadata field array @@ -158,11 +174,11 @@ const AddMetadataToEntity: FC = ({ }) // Determine if isRequiredFor should be true or false based on is_required_for array const isRequiredFor = !!matchingItem?.is_required_for.length - setHasMetadataRequired?.(isRequiredFor) + // Return the metadata field with additional metadata model field information including isRequiredFor return { ...meta, - isRequiredFor: isRequiredFor || false, + isRequiredFor, metadataModelFieldId: matchingItem ? matchingItem.id : null, } }) diff --git a/frontend/web/components/modals/create-feature/index.js b/frontend/web/components/modals/create-feature/index.js index ea838aef32c5..ccdf8758557e 100644 --- a/frontend/web/components/modals/create-feature/index.js +++ b/frontend/web/components/modals/create-feature/index.js @@ -829,9 +829,6 @@ const Index = class extends Component { > {({ permission: projectAdmin }) => { this.state.skipSaveProjectFeature = !createFeature - const _hasMetadataRequired = - this.state.hasMetadataRequired && - !projectFlag.metadata?.length return (
@@ -1739,7 +1736,7 @@ const Index = class extends Component { isSaving || !projectFlag.name || invalid || - _hasMetadataRequired + this.state.hasMetadataRequired } > {isSaving @@ -1820,11 +1817,15 @@ const Index = class extends Component { } onHasMetadataRequiredChange={( hasMetadataRequired, - ) => + ) => { + console.log( + 'hasMetadataRequired', + hasMetadataRequired, + ) this.setState({ hasMetadataRequired, }) - } + }} featureError={ this.parseError(error).featureError } @@ -1842,7 +1843,9 @@ const Index = class extends Component { featureLimitPercentage={ this.state.featureLimitAlert.percentage } - hasMetadataRequired={_hasMetadataRequired} + hasMetadataRequired={ + this.state.hasMetadataRequired + } />
)} diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx index 456f0e068fd4..28d4b1ffa273 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx @@ -75,7 +75,7 @@ const FeatureSettings: FC = ({ )} {metadataEnable && featureContentType?.id && !identity && ( <> - + {/* */} Date: Thu, 5 Feb 2026 16:24:54 +0100 Subject: [PATCH 03/21] feat: refactor-metadata-with-hooks --- .../common/hooks/useEntityMetadataFields.ts | 159 ++++++ .../__tests__/metadataValidation.test.ts | 90 ++++ frontend/common/utils/metadataValidation.ts | 29 + .../metadata/AddMetadataToEntity.tsx | 496 ++++++------------ .../create-feature/tabs/FeatureSettings.tsx | 2 +- 5 files changed, 438 insertions(+), 338 deletions(-) create mode 100644 frontend/common/hooks/useEntityMetadataFields.ts create mode 100644 frontend/common/utils/__tests__/metadataValidation.test.ts create mode 100644 frontend/common/utils/metadataValidation.ts diff --git a/frontend/common/hooks/useEntityMetadataFields.ts b/frontend/common/hooks/useEntityMetadataFields.ts new file mode 100644 index 000000000000..4848a12d282c --- /dev/null +++ b/frontend/common/hooks/useEntityMetadataFields.ts @@ -0,0 +1,159 @@ +import { useMemo } from 'react' +import { sortBy } from 'lodash' +import { useGetMetadataModelFieldListQuery } from 'common/services/useMetadataModelField' +import { useGetMetadataFieldListQuery } from 'common/services/useMetadataField' +import { useGetSegmentQuery } from 'common/services/useSegment' +import { useGetEnvironmentQuery } from 'common/services/useEnvironment' +import { useGetProjectFlagQuery } from 'common/services/useProjectFlag' +import { MetadataField, Metadata } from 'common/types/responses' + +export type CustomMetadataField = MetadataField & { + metadataModelFieldId: number | string | null + isRequiredFor: boolean + model_field?: string | number + hasValue?: boolean + field_value?: string +} + +type UseEntityMetadataFieldsParams = { + organisationId: number + projectId: number + entityContentType: number + entityType: 'feature' | 'segment' | 'environment' + entityId?: number +} + +type UseEntityMetadataFieldsResult = { + metadataFields: CustomMetadataField[] + isLoading: boolean +} + +/** + * Merges field definitions with existing entity values. + * This takes the list of metadata field definitions and enriches them + * with any existing values from the entity. + */ +function mergeFieldDefinitionsWithValues( + fieldDefinitions: CustomMetadataField[], + existingValues: Metadata[], +): CustomMetadataField[] { + return fieldDefinitions.map((field) => { + const existingValue = existingValues.find( + (v) => v.model_field === field.metadataModelFieldId, + ) + return { + ...field, + field_value: existingValue?.field_value ?? '', + hasValue: !!existingValue, + } + }) +} + +/** + * Hook that fetches and merges metadata fields for an entity. + * + * This encapsulates the complex data fetching and merging logic that was + * previously spread across multiple useEffects in AddMetadataToEntity. + */ +export function useEntityMetadataFields({ + entityContentType, + entityId, + entityType, + organisationId, + projectId, +}: UseEntityMetadataFieldsParams): UseEntityMetadataFieldsResult { + // Fetch all metadata field definitions for the organisation + const { data: metadataFieldList, isLoading: metadataFieldListLoading } = + useGetMetadataFieldListQuery({ + organisation: organisationId, + }) + + // Fetch all model field mappings + const { data: metadataModelFieldList, isLoading: metadataModelFieldLoading } = + useGetMetadataModelFieldListQuery({ + organisation_id: organisationId, + }) + + // Fetch entity-specific data based on type + const { data: projectFeatureData, isLoading: projectFeatureLoading } = + useGetProjectFlagQuery( + { id: entityId!, project: projectId }, + { skip: entityType !== 'feature' || !entityId }, + ) + + const { data: segmentData, isLoading: segmentLoading } = useGetSegmentQuery( + { id: entityId!, projectId }, + { skip: entityType !== 'segment' || !entityId || !projectId }, + ) + + const { data: envData, isLoading: envLoading } = useGetEnvironmentQuery( + { id: entityId! }, + { skip: entityType !== 'environment' || !entityId }, + ) + + // Compute the merged metadata fields + const metadataFields = useMemo(() => { + if (!metadataFieldList || !metadataModelFieldList) { + return [] + } + + // Filter metadata fields that apply to this content type + const fieldsForContentType: CustomMetadataField[] = + metadataFieldList.results + .filter((meta) => + metadataModelFieldList.results.some( + (item) => + item.field === meta.id && item.content_type === entityContentType, + ), + ) + .map((meta) => { + const matchingItem = metadataModelFieldList.results.find( + (item) => + item.field === meta.id && item.content_type === entityContentType, + ) + return { + ...meta, + isRequiredFor: !!matchingItem?.is_required_for.length, + metadataModelFieldId: matchingItem ? matchingItem.id : null, + } + }) + + // Get existing values from the entity + let existingValues: Metadata[] = [] + if (entityType === 'feature' && projectFeatureData?.metadata) { + existingValues = projectFeatureData.metadata + } else if (entityType === 'segment' && segmentData?.metadata) { + existingValues = segmentData.metadata + } else if (entityType === 'environment' && envData?.metadata) { + existingValues = envData.metadata + } + + // Merge field definitions with existing values + const mergedMetadata = mergeFieldDefinitionsWithValues( + fieldsForContentType, + existingValues, + ) + + return sortBy(mergedMetadata, (m) => (m.isRequiredFor ? -1 : 1)) + }, [ + metadataFieldList, + metadataModelFieldList, + entityContentType, + entityType, + projectFeatureData, + segmentData, + envData, + ]) + + const isLoading = + metadataFieldListLoading || + metadataModelFieldLoading || + (entityType === 'feature' && entityId && projectFeatureLoading) || + (entityType === 'segment' && entityId && segmentLoading) || + (entityType === 'environment' && entityId && envLoading) + + return { + isLoading: !!isLoading, + metadataFields, + } +} diff --git a/frontend/common/utils/__tests__/metadataValidation.test.ts b/frontend/common/utils/__tests__/metadataValidation.test.ts new file mode 100644 index 000000000000..0f2d110734d5 --- /dev/null +++ b/frontend/common/utils/__tests__/metadataValidation.test.ts @@ -0,0 +1,90 @@ +import { getGlobalMetadataValidationState } from 'common/utils/metadataValidation' +import { CustomMetadataField } from 'common/hooks/useEntityMetadataFields' + +const createField = ( + partialField: Partial = {}, +): CustomMetadataField => ({ + description: 'A test field', + field_value: '', + hasValue: false, + id: 1, + isRequiredFor: false, + metadataModelFieldId: 1, + name: 'Test Field', + organisation: 1, + type: 'str', + ...partialField, +}) + +describe('getMetadataValidationState', () => { + it('returns all zeros for empty fields array', () => { + const result = getGlobalMetadataValidationState([]) + + expect(result).toEqual({ + hasUnfilledRequired: false, + totalFilledRequired: 0, + totalRequired: 0, + }) + }) + + it('returns hasUnfilledRequired false when no required fields', () => { + const fields = [ + createField({ id: 1, isRequiredFor: false }), + createField({ id: 2, isRequiredFor: false }), + ] + + const result = getGlobalMetadataValidationState(fields) + + expect(result).toEqual({ + hasUnfilledRequired: false, + totalFilledRequired: 0, + totalRequired: 0, + }) + }) + + it('returns hasUnfilledRequired false when required field is filled', () => { + const fields = [ + createField({ field_value: 'some value', id: 1, isRequiredFor: true }), + ] + + const result = getGlobalMetadataValidationState(fields) + + expect(result).toEqual({ + hasUnfilledRequired: false, + totalFilledRequired: 1, + totalRequired: 1, + }) + }) + + it('returns hasUnfilledRequired true when some required fields are unfilled', () => { + const fields = [ + createField({ field_value: 'filled', id: 1, isRequiredFor: true }), + createField({ field_value: '', id: 2, isRequiredFor: true }), + createField({ field_value: '', id: 3, isRequiredFor: false }), + ] + + const result = getGlobalMetadataValidationState(fields) + + expect(result).toEqual({ + hasUnfilledRequired: true, + totalFilledRequired: 1, + totalRequired: 2, + }) + }) + + it('returns hasUnfilledRequired false when all required fields are filled', () => { + const fields = [ + createField({ field_value: 'filled', id: 1, isRequiredFor: true }), + createField({ field_value: 'also filled', id: 2, isRequiredFor: true }), + createField({ field_value: '', id: 3, isRequiredFor: false }), + ] + + const result = getGlobalMetadataValidationState(fields) + + expect(result).toEqual({ + hasUnfilledRequired: false, + totalFilledRequired: 2, + totalRequired: 2, + }) + }) +}) diff --git a/frontend/common/utils/metadataValidation.ts b/frontend/common/utils/metadataValidation.ts new file mode 100644 index 000000000000..e84dc3522c4d --- /dev/null +++ b/frontend/common/utils/metadataValidation.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react' +import { CustomMetadataField } from 'common/hooks/useEntityMetadataFields' + +export type MetadataValidationState = { + hasUnfilledRequired: boolean + totalRequired: number + totalFilledRequired: number +} + +export function getGlobalMetadataValidationState( + fields: CustomMetadataField[], +): MetadataValidationState { + const totalRequired = fields.filter((f) => f.isRequiredFor).length + const totalFilledRequired = fields.filter( + (f) => f.isRequiredFor && f.field_value && f.field_value !== '', + ).length + + return { + hasUnfilledRequired: + totalRequired > 0 && totalFilledRequired < totalRequired, + totalFilledRequired, + totalRequired, + } +} +export function useGlobalMetadataValidation( + fields: CustomMetadataField[], +): MetadataValidationState { + return useMemo(() => getGlobalMetadataValidationState(fields), [fields]) +} diff --git a/frontend/web/components/metadata/AddMetadataToEntity.tsx b/frontend/web/components/metadata/AddMetadataToEntity.tsx index b62262fe3572..c78222de0d6b 100644 --- a/frontend/web/components/metadata/AddMetadataToEntity.tsx +++ b/frontend/web/components/metadata/AddMetadataToEntity.tsx @@ -1,43 +1,57 @@ -import React, { FC, useEffect, useState } from 'react' +import React, { FC, useCallback, useEffect, useState } from 'react' import PanelSearch from 'components/PanelSearch' import Button from 'components/base/forms/Button' -import { useGetMetadataModelFieldListQuery } from 'common/services/useMetadataModelField' -import { useGetMetadataFieldListQuery } from 'common/services/useMetadataField' -import { useGetSegmentQuery } from 'common/services/useSegment' -import { - useGetEnvironmentQuery, - useUpdateEnvironmentMutation, -} from 'common/services/useEnvironment' -import { MetadataField, Metadata } from 'common/types/responses' +import { useUpdateEnvironmentMutation } from 'common/services/useEnvironment' +import { Metadata } from 'common/types/responses' import Utils from 'common/utils/utils' -import { useGetProjectFlagQuery } from 'common/services/useProjectFlag' -import { sortBy } from 'lodash' import Switch from 'components/Switch' import InputGroup from 'components/base/forms/InputGroup' +import { + useEntityMetadataFields, + CustomMetadataField, +} from 'common/hooks/useEntityMetadataFields' +import { useGlobalMetadataValidation } from 'common/utils/metadataValidation' -export type CustomMetadataField = MetadataField & { - metadataModelFieldId: number | string | null - isRequiredFor: boolean - model_field?: string | number - metadataEntity?: boolean - field_value?: string -} - -type CustomMetadata = (Metadata & CustomMetadataField) | null +export type { CustomMetadataField } -type AddMetadataToEntityType = { +type AddMetadataToEntityProps = { isCloningEnvironment?: boolean - organisationId: string - projectId: string | number + organisationId: number + projectId: number entityContentType: number - entityId: string + entityId?: number entity: string envName?: string - onChange?: (m: CustomMetadataField[]) => void + onChange?: (metadata: Metadata[]) => void setHasMetadataRequired?: (b: boolean) => void } -const AddMetadataToEntity: FC = ({ +function formatMetadataToApi(fields: CustomMetadataField[]): Metadata[] { + return fields + .filter((f) => f.hasValue) + .map(({ field_value, metadataModelFieldId }) => ({ + field_value: field_value ?? '', + model_field: metadataModelFieldId as number, + })) +} + +type MetadataErrorResponse = { + data?: { + metadata?: Array<{ + non_field_errors?: string[] + [key: string]: unknown + }> + } +} + +function getMetadataErrors(error: MetadataErrorResponse): string { + const metadataErrors = error?.data?.metadata + if (!metadataErrors) return '' + + return metadataErrors.flatMap((m) => m.non_field_errors ?? []).join('\n') +} + +const AddMetadataToEntity: FC = ({ entity, entityContentType, entityId, @@ -48,364 +62,172 @@ const AddMetadataToEntity: FC = ({ projectId, setHasMetadataRequired, }) => { - const { data: metadataFieldList, isSuccess: metadataFieldListLoaded } = - useGetMetadataFieldListQuery({ - organisation: organisationId, - }) - const { - data: metadataModelFieldList, - isSuccess: metadataModelFieldListLoaded, - } = useGetMetadataModelFieldListQuery({ - organisation_id: organisationId, + const { isLoading, metadataFields: initialFields } = useEntityMetadataFields({ + entityContentType, + entityId: entityId, + entityType: entity as 'feature' | 'segment' | 'environment', + organisationId, + projectId, }) - const { data: projectFeatureData, isSuccess: projectFeatureDataLoaded } = - useGetProjectFlagQuery( - { - id: entityId, - project: projectId, - }, - { skip: entity !== 'feature' || !entityId }, - ) - - const { data: segmentData, isSuccess: segmentDataLoaded } = - useGetSegmentQuery( - { - id: `${entityId}`, - projectId: `${projectId}`, - }, - { skip: entity !== 'segment' || !entityId }, - ) - - const { data: envData, isSuccess: envDataLoaded } = useGetEnvironmentQuery( - { id: entityId }, - { skip: entity !== 'environment' || !entityId }, + const [metadataFields, setMetadataFields] = useState( + [], ) + const [hasChanges, setHasChanges] = useState(false) - const [updateEnvironment] = useUpdateEnvironmentMutation() - - const [ - metadataFieldsAssociatedtoEntity, - setMetadataFieldsAssociatedtoEntity, - ] = useState() + const { hasUnfilledRequired } = useGlobalMetadataValidation(metadataFields) useEffect(() => { - if (metadataFieldsAssociatedtoEntity?.length && metadataChanged) { - const metadataParsed = metadataFieldsAssociatedtoEntity - .filter((m) => m.metadataEntity) - .map((i) => { - const { metadataModelFieldId, ...rest } = i - return { model_field: metadataModelFieldId, ...rest } - }) - onChange?.(metadataParsed as CustomMetadataField[]) + if (initialFields.length > 0 && metadataFields.length === 0) { + setMetadataFields(initialFields) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [metadataFieldsAssociatedtoEntity]) - - const [metadataChanged, setMetadataChanged] = useState(false) + }, [initialFields, metadataFields.length]) useEffect(() => { - if (!metadataFieldsAssociatedtoEntity) return - - const totalRequired = metadataFieldsAssociatedtoEntity.filter( - (m) => m.isRequiredFor, - ).length - const totalFilledRequired = metadataFieldsAssociatedtoEntity.filter( - (m) => m.field_value && m.field_value !== '' && m.isRequiredFor, - ).length - - setHasMetadataRequired?.( - totalRequired > 0 && totalFilledRequired < totalRequired, - ) + setHasMetadataRequired?.(hasUnfilledRequired) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [metadataFieldsAssociatedtoEntity]) + }, [hasUnfilledRequired]) + + const handleFieldChange = useCallback( + (fieldId: number, newValue: string) => { + setMetadataFields((prev) => { + const updatedMetadataFields = prev.map((field) => + field.id === fieldId + ? { ...field, field_value: newValue, hasValue: !!newValue } + : field, + ) - const mergeMetadataEntityWithMetadataField = ( - metadata: Metadata[], // Metadata array - metadataField: CustomMetadataField[], // Custom metadata field array - ) => { - // Create a map of metadata fields using metadataModelFieldId as key - const map = new Map( - metadataField.map((item) => [item.metadataModelFieldId, item]), - ) + // Propagate the change to upstream parents + if (entity !== 'environment' || isCloningEnvironment) { + const formattedMetadata = formatMetadataToApi(updatedMetadataFields) + onChange?.(formattedMetadata) + } + + return updatedMetadataFields + }) + setHasChanges(true) + }, + [entity, isCloningEnvironment, onChange], + ) - // Merge metadata fields with metadata entities - return metadataField.map((item) => { - const mergedItem = { - ...item, // Spread the properties of the metadata field - ...(map.get(item.model_field!) || {}), // Get the corresponding metadata field from the map - ...(metadata.find((m) => m.model_field === item.metadataModelFieldId) || - {}), // Find the corresponding metadata entity - } + const [updateEnvironment] = useUpdateEnvironmentMutation() - // Determine if metadata entity exists - mergedItem.metadataEntity = - mergedItem.metadataModelFieldId !== undefined && - mergedItem.model_field !== undefined + const handleEnvironmentSave = async () => { + if (!envName || !entityId) return - return mergedItem // Return the merged item + const result = await updateEnvironment({ + body: { + metadata: formatMetadataToApi(metadataFields), + name: envName, + project: projectId, + }, + id: entityId, }) - } - useEffect(() => { - if ( - metadataFieldList && - metadataFieldListLoaded && - metadataModelFieldList && - metadataModelFieldListLoaded - ) { - // Filter metadata fields based on the provided content type - const metadataForContentType = metadataFieldList.results - // Filter metadata fields that have corresponding entries in the metadata model field list - .filter((meta) => { - return metadataModelFieldList.results.some((item) => { - return ( - item.field === meta.id && item.content_type === entityContentType - ) - }) - }) - // Map each filtered metadata field to include additional information from the metadata model field list - .map((meta) => { - // Find the matching item in the metadata model field list - const matchingItem = metadataModelFieldList.results.find((item) => { - return ( - item.field === meta.id && item.content_type === entityContentType - ) - }) - // Determine if isRequiredFor should be true or false based on is_required_for array - const isRequiredFor = !!matchingItem?.is_required_for.length - - // Return the metadata field with additional metadata model field information including isRequiredFor - return { - ...meta, - isRequiredFor, - metadataModelFieldId: matchingItem ? matchingItem.id : null, - } - }) - if (projectFeatureData?.metadata && projectFeatureDataLoaded) { - const mergedFeatureEntity = mergeMetadataEntityWithMetadataField( - projectFeatureData?.metadata, - metadataForContentType, - ) - const sortedArray = sortBy(mergedFeatureEntity, (m) => - m.isRequiredFor ? -1 : 1, - ) - setMetadataFieldsAssociatedtoEntity(sortedArray) - } else if (segmentData?.metadata && segmentDataLoaded) { - const mergedSegmentEntity = mergeMetadataEntityWithMetadataField( - segmentData?.metadata, - metadataForContentType, - ) - const sortedArray = sortBy(mergedSegmentEntity, (m) => - m.isRequiredFor ? -1 : 1, - ) - setMetadataFieldsAssociatedtoEntity(sortedArray) - } else if (envData?.metadata && envDataLoaded) { - const mergedEnvEntity = mergeMetadataEntityWithMetadataField( - envData?.metadata, - metadataForContentType, - ) - const sortedArray = sortBy(mergedEnvEntity, (m) => - m.isRequiredFor ? -1 : 1, - ) - setMetadataFieldsAssociatedtoEntity(sortedArray) - } else { - const sortedArray = sortBy(metadataForContentType, (m) => - m.isRequiredFor ? -1 : 1, - ) - setMetadataFieldsAssociatedtoEntity(sortedArray) - } + if ('error' in result) { + toast(getMetadataErrors(result.error as MetadataErrorResponse), 'danger') + } else { + toast('Environment Field Updated') + setHasChanges(false) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - metadataFieldList, - metadataFieldListLoaded, - metadataModelFieldList, - metadataModelFieldListLoaded, - projectFeatureDataLoaded, - projectFeatureData, - entityId, - envData, - envDataLoaded, - segmentData, - segmentDataLoaded, - ]) - - const getMetadataErrors = (error: any) => { - const nonFieldErrors = - error?.data?.metadata?.map( - (metadata: any) => metadata?.non_field_errors, - ) || [] - const fieldErrors = - error?.data?.metadata?.map((metadata: any) => metadata) || [] - - const allErrors = [...nonFieldErrors, ...fieldErrors] - - return allErrors.join('\n') } return ( - <> - - - Field - Value - - } - items={metadataFieldsAssociatedtoEntity} - renderRow={(m) => { - return ( - { - setMetadataFieldsAssociatedtoEntity((prevState) => - prevState?.map((metadata) => { - if (metadata.id === m?.id) { - return { - ...metadata, - field_value: m?.field_value, - metadataEntity: !!m?.field_value, - } - } - return metadata - }), - ) - setMetadataChanged(true) - }} - /> - ) - }} - renderNoResults={ - - No custom fields configured for {entity}s. Add custom fields in - your{' '} - - Organisation Settings - - . - - } - /> - {entity === 'environment' && !isCloningEnvironment && ( -
- -
+ + + Field + Value + + } + items={metadataFields} + renderRow={(field: CustomMetadataField) => ( + )} - - + renderNoResults={ + + No custom fields configured for {entity}s. Add custom fields in your{' '} + + Organisation Settings + + . + + } + /> + {entity === 'environment' && !isCloningEnvironment && ( +
+ +
+ )} +
) } -type MetadataRowType = { +type MetadataRowProps = { metadata: CustomMetadataField - getMetadataValue?: (metadata: CustomMetadata) => void - entity: string + onFieldChange: (fieldId: number, value: string) => void } -const MetadataRow: FC = ({ - entity, - getMetadataValue, - metadata, -}) => { - const [metadataValueChanged, setMetadataValueChanged] = - useState(false) - const metadataValue = - metadata?.type === 'bool' - ? metadata?.field_value === 'true' - : metadata?.field_value || '' - const handleChange = (newMetadataValue: string | boolean) => { - setMetadataValueChanged(false) - const updatedMetadataObject = { ...metadata } - updatedMetadataObject.field_value = - metadata?.type === 'bool' ? `${!newMetadataValue}` : `${newMetadataValue}` - getMetadataValue?.(updatedMetadataObject as CustomMetadata) - } +const MetadataRow: FC = ({ metadata, onFieldChange }) => { + const displayValue = + metadata.type === 'bool' + ? metadata.field_value === 'true' + : metadata.field_value || '' - const isRequiredForAndCorrectType = - metadata?.isRequiredFor && - Utils.validateMetadataType(metadata?.type, metadataValue) - const isNotRequiredAndCorrectType = - !!metadataValue && Utils.validateMetadataType(metadata?.type, metadataValue) - const isEmptyAuthorized = !metadataValue && !metadata?.isRequiredFor + const handleChange = (newValue: string | boolean) => { + const stringValue = metadata.type === 'bool' ? `${newValue}` : `${newValue}` + onFieldChange(metadata.id, stringValue) + } + const isEmpty = !displayValue || displayValue === '' + const isValidType = Utils.validateMetadataType(metadata.type, displayValue) + const isValid = isEmpty ? !metadata.isRequiredFor : isValidType return ( - {metadataValueChanged && entity !== 'segment' && ( -
{'*'}
- )} - {`${metadata?.name} ${ - metadata?.isRequiredFor ? '*' : '' - }`} - {metadata?.type === 'bool' ? ( + {metadata.name} + {metadata.type === 'bool' ? ( { - setMetadataValueChanged(true) - handleChange(!metadataValue) + const currentBool = + displayValue === true || displayValue === 'true' + handleChange(!currentBool) }} /> ) : ( { - setMetadataValueChanged(true) handleChange(Utils.safeParseEventValue(e)) }} type='text' diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx index 28d4b1ffa273..456f0e068fd4 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx @@ -75,7 +75,7 @@ const FeatureSettings: FC = ({ )} {metadataEnable && featureContentType?.id && !identity && ( <> - {/* */} + Date: Thu, 5 Feb 2026 17:26:00 +0100 Subject: [PATCH 04/21] feat: consolidated-requests-and-merged-strategy-in-rtk --- .../common/hooks/useEntityMetadataFields.ts | 159 ------------------ frontend/common/services/useMetadataField.ts | 111 ++++++++++++ frontend/common/types/metadata-field.ts | 9 + .../__tests__/mergeMetadataFields.test.ts | 103 ++++++++++++ .../__tests__/metadataValidation.test.ts | 2 +- frontend/common/utils/mergeMetadataFields.ts | 60 +++++++ frontend/common/utils/metadataValidation.ts | 2 +- .../metadata/AddMetadataToEntity.tsx | 32 ++-- .../pages/CreateEnvironmentPage.tsx | 74 ++++---- 9 files changed, 342 insertions(+), 210 deletions(-) delete mode 100644 frontend/common/hooks/useEntityMetadataFields.ts create mode 100644 frontend/common/types/metadata-field.ts create mode 100644 frontend/common/utils/__tests__/mergeMetadataFields.test.ts create mode 100644 frontend/common/utils/mergeMetadataFields.ts diff --git a/frontend/common/hooks/useEntityMetadataFields.ts b/frontend/common/hooks/useEntityMetadataFields.ts deleted file mode 100644 index 4848a12d282c..000000000000 --- a/frontend/common/hooks/useEntityMetadataFields.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useMemo } from 'react' -import { sortBy } from 'lodash' -import { useGetMetadataModelFieldListQuery } from 'common/services/useMetadataModelField' -import { useGetMetadataFieldListQuery } from 'common/services/useMetadataField' -import { useGetSegmentQuery } from 'common/services/useSegment' -import { useGetEnvironmentQuery } from 'common/services/useEnvironment' -import { useGetProjectFlagQuery } from 'common/services/useProjectFlag' -import { MetadataField, Metadata } from 'common/types/responses' - -export type CustomMetadataField = MetadataField & { - metadataModelFieldId: number | string | null - isRequiredFor: boolean - model_field?: string | number - hasValue?: boolean - field_value?: string -} - -type UseEntityMetadataFieldsParams = { - organisationId: number - projectId: number - entityContentType: number - entityType: 'feature' | 'segment' | 'environment' - entityId?: number -} - -type UseEntityMetadataFieldsResult = { - metadataFields: CustomMetadataField[] - isLoading: boolean -} - -/** - * Merges field definitions with existing entity values. - * This takes the list of metadata field definitions and enriches them - * with any existing values from the entity. - */ -function mergeFieldDefinitionsWithValues( - fieldDefinitions: CustomMetadataField[], - existingValues: Metadata[], -): CustomMetadataField[] { - return fieldDefinitions.map((field) => { - const existingValue = existingValues.find( - (v) => v.model_field === field.metadataModelFieldId, - ) - return { - ...field, - field_value: existingValue?.field_value ?? '', - hasValue: !!existingValue, - } - }) -} - -/** - * Hook that fetches and merges metadata fields for an entity. - * - * This encapsulates the complex data fetching and merging logic that was - * previously spread across multiple useEffects in AddMetadataToEntity. - */ -export function useEntityMetadataFields({ - entityContentType, - entityId, - entityType, - organisationId, - projectId, -}: UseEntityMetadataFieldsParams): UseEntityMetadataFieldsResult { - // Fetch all metadata field definitions for the organisation - const { data: metadataFieldList, isLoading: metadataFieldListLoading } = - useGetMetadataFieldListQuery({ - organisation: organisationId, - }) - - // Fetch all model field mappings - const { data: metadataModelFieldList, isLoading: metadataModelFieldLoading } = - useGetMetadataModelFieldListQuery({ - organisation_id: organisationId, - }) - - // Fetch entity-specific data based on type - const { data: projectFeatureData, isLoading: projectFeatureLoading } = - useGetProjectFlagQuery( - { id: entityId!, project: projectId }, - { skip: entityType !== 'feature' || !entityId }, - ) - - const { data: segmentData, isLoading: segmentLoading } = useGetSegmentQuery( - { id: entityId!, projectId }, - { skip: entityType !== 'segment' || !entityId || !projectId }, - ) - - const { data: envData, isLoading: envLoading } = useGetEnvironmentQuery( - { id: entityId! }, - { skip: entityType !== 'environment' || !entityId }, - ) - - // Compute the merged metadata fields - const metadataFields = useMemo(() => { - if (!metadataFieldList || !metadataModelFieldList) { - return [] - } - - // Filter metadata fields that apply to this content type - const fieldsForContentType: CustomMetadataField[] = - metadataFieldList.results - .filter((meta) => - metadataModelFieldList.results.some( - (item) => - item.field === meta.id && item.content_type === entityContentType, - ), - ) - .map((meta) => { - const matchingItem = metadataModelFieldList.results.find( - (item) => - item.field === meta.id && item.content_type === entityContentType, - ) - return { - ...meta, - isRequiredFor: !!matchingItem?.is_required_for.length, - metadataModelFieldId: matchingItem ? matchingItem.id : null, - } - }) - - // Get existing values from the entity - let existingValues: Metadata[] = [] - if (entityType === 'feature' && projectFeatureData?.metadata) { - existingValues = projectFeatureData.metadata - } else if (entityType === 'segment' && segmentData?.metadata) { - existingValues = segmentData.metadata - } else if (entityType === 'environment' && envData?.metadata) { - existingValues = envData.metadata - } - - // Merge field definitions with existing values - const mergedMetadata = mergeFieldDefinitionsWithValues( - fieldsForContentType, - existingValues, - ) - - return sortBy(mergedMetadata, (m) => (m.isRequiredFor ? -1 : 1)) - }, [ - metadataFieldList, - metadataModelFieldList, - entityContentType, - entityType, - projectFeatureData, - segmentData, - envData, - ]) - - const isLoading = - metadataFieldListLoading || - metadataModelFieldLoading || - (entityType === 'feature' && entityId && projectFeatureLoading) || - (entityType === 'segment' && entityId && segmentLoading) || - (entityType === 'environment' && entityId && envLoading) - - return { - isLoading: !!isLoading, - metadataFields, - } -} diff --git a/frontend/common/services/useMetadataField.ts b/frontend/common/services/useMetadataField.ts index e33711b000bd..c089ee1494a2 100644 --- a/frontend/common/services/useMetadataField.ts +++ b/frontend/common/services/useMetadataField.ts @@ -2,6 +2,44 @@ import { Res } from 'common/types/responses' import { Req } from 'common/types/requests' import { service } from 'common/service' import Utils from 'common/utils/utils' +import { CustomMetadataField } from 'common/types/metadata-field' +import { + Environment, + MetadataField, + MetadataModelField, + PagedResponse, + ProjectFlag, + Segment, +} from 'common/types/responses' +import { mergeMetadataFields } from 'common/utils/mergeMetadataFields' + +type EntityType = 'feature' | 'segment' | 'environment' + +type EntityMetadataParams = { + organisationId: number + projectId: number + entityContentType: number + entityType: EntityType + entityId?: number +} + +type EntityData = ProjectFlag | Segment | Environment + +function getEntityUrl(params: EntityMetadataParams): string | null { + const { entityId, entityType, projectId } = params + if (!entityId) return null + + switch (entityType) { + case 'feature': + return `projects/${projectId}/features/${entityId}/` + case 'segment': + return `projects/${projectId}/segments/${entityId}/` + case 'environment': + return `environments/${entityId}/` + default: + return null + } +} export const metadataService = service .enhanceEndpoints({ addTagTypes: ['Metadata'] }) @@ -28,6 +66,67 @@ export const metadataService = service url: `metadata/fields/${query.id}/`, }), }), + getEntityMetadataFields: builder.query< + CustomMetadataField[], + EntityMetadataParams + >({ + providesTags: (_res, _err, arg) => [ + { + id: `${arg.entityType}-${arg.entityId ?? 'new'}-${ + arg.entityContentType + }`, + type: 'Metadata', + }, + ], + queryFn: async (arg, _api, _extraOptions, baseQuery) => { + const entityUrl = getEntityUrl(arg) + + // Build queries to run in parallel + const queries: Promise<{ data?: unknown; error?: unknown }>[] = [ + baseQuery({ + url: `metadata/fields/?${Utils.toParam({ + organisation: arg.organisationId, + })}`, + }), + baseQuery({ + url: `organisations/${arg.organisationId}/metadata-model-fields/`, + }), + ] + + // Only fetch entity data if we have an entityId + if (entityUrl) { + queries.push(baseQuery({ url: entityUrl })) + } + + // Fetch all in parallel + const results = await Promise.all(queries) + + const [fieldsRes, modelFieldsRes, entityRes] = results + + // Handle errors + if (fieldsRes.error) { + return { error: fieldsRes.error as Res['metadataList'] } + } + if (modelFieldsRes.error) { + return { + error: modelFieldsRes.error as Res['metadataModelFieldList'], + } + } + if (entityRes?.error) { + return { error: entityRes.error as EntityData } + } + + // Merge and return + const mergedMetadata = mergeMetadataFields( + fieldsRes.data as PagedResponse, + modelFieldsRes.data as PagedResponse, + entityRes?.data as EntityData | null, + arg.entityContentType, + ) + + return { data: mergedMetadata } + }, + }), getMetadataField: builder.query< Res['metadataField'], Req['getMetadataField'] @@ -119,11 +218,23 @@ export async function updateMetadata( metadataService.endpoints.updateMetadataField.initiate(data, options), ) } +export async function getEntityMetadataFields( + store: any, + data: EntityMetadataParams, + options?: Parameters< + typeof metadataService.endpoints.getEntityMetadataFields.initiate + >[1], +) { + return store.dispatch( + metadataService.endpoints.getEntityMetadataFields.initiate(data, options), + ) +} // END OF FUNCTION_EXPORTS export const { useCreateMetadataFieldMutation, useDeleteMetadataFieldMutation, + useGetEntityMetadataFieldsQuery, useGetMetadataFieldListQuery, useGetMetadataFieldQuery, useUpdateMetadataFieldMutation, diff --git a/frontend/common/types/metadata-field.ts b/frontend/common/types/metadata-field.ts new file mode 100644 index 000000000000..a71dbf84088c --- /dev/null +++ b/frontend/common/types/metadata-field.ts @@ -0,0 +1,9 @@ +import { MetadataField } from './responses' + +export type CustomMetadataField = MetadataField & { + metadataModelFieldId: number | string | null + isRequiredFor: boolean + model_field?: string | number + hasValue?: boolean + field_value?: string +} diff --git a/frontend/common/utils/__tests__/mergeMetadataFields.test.ts b/frontend/common/utils/__tests__/mergeMetadataFields.test.ts new file mode 100644 index 000000000000..1d23d15eab30 --- /dev/null +++ b/frontend/common/utils/__tests__/mergeMetadataFields.test.ts @@ -0,0 +1,103 @@ +import { mergeMetadataFields } from 'common/utils/mergeMetadataFields' +import { + MetadataField, + MetadataModelField, + PagedResponse, +} from 'common/types/responses' + +const createFieldList = ( + fields: Partial[], +): PagedResponse => ({ + results: fields.map((f, idx) => ({ + description: 'Test description', + id: idx + 1, + name: `Field ${idx + 1}`, + organisation: 1, + type: 'str', + ...f, + })) as MetadataField[], +}) + +const createModelFieldList = ( + modelFields: Partial[], +): PagedResponse => ({ + results: modelFields.map((mf, idx) => ({ + content_type: 100, + field: idx + 1, + id: `${idx + 10}`, + is_required_for: [], + ...mf, + })) as MetadataModelField[], +}) + +describe('mergeMetadataFields', () => { + it('merges field definitions with existing values', () => { + const fieldList = createFieldList([{ id: 1, name: 'Field 1' }]) + const modelFieldList = createModelFieldList([ + { content_type: 100, field: 1, id: '10', is_required_for: [] }, + ]) + const entityData = { + metadata: [{ field_value: 'existing value', model_field: '10' }], + } + + const result = mergeMetadataFields( + fieldList, + modelFieldList, + entityData, + 100, + ) + + expect(result).toHaveLength(1) + expect(result[0].field_value).toBe('existing value') + expect(result[0].hasValue).toBe(true) + }) + + it('sets empty field_value when no existing value or null entity', () => { + const fieldList = createFieldList([{ id: 1, name: 'Field 1' }]) + const modelFieldList = createModelFieldList([ + { content_type: 100, field: 1, id: '10', is_required_for: [] }, + ]) + + const result = mergeMetadataFields(fieldList, modelFieldList, null, 100) + + expect(result[0].field_value).toBe('') + expect(result[0].hasValue).toBe(false) + }) + + it('marks field as required when is_required_for has entries', () => { + const fieldList = createFieldList([{ id: 1, name: 'Required Field' }]) + const modelFieldList = createModelFieldList([ + { + content_type: 100, + field: 1, + id: '10', + is_required_for: [{ content_type: 50 }], + }, + ]) + + const result = mergeMetadataFields(fieldList, modelFieldList, null, 100) + + expect(result[0].isRequiredFor).toBe(true) + }) + + it('sorts required fields first', () => { + const fieldList = createFieldList([ + { id: 1, name: 'Optional' }, + { id: 2, name: 'Required' }, + ]) + const modelFieldList = createModelFieldList([ + { content_type: 100, field: 1, id: '10', is_required_for: [] }, + { + content_type: 100, + field: 2, + id: '11', + is_required_for: [{ content_type: 50 }], + }, + ]) + + const result = mergeMetadataFields(fieldList, modelFieldList, null, 100) + + expect(result[0].name).toBe('Required') + expect(result[1].name).toBe('Optional') + }) +}) diff --git a/frontend/common/utils/__tests__/metadataValidation.test.ts b/frontend/common/utils/__tests__/metadataValidation.test.ts index 0f2d110734d5..3e2300f15a02 100644 --- a/frontend/common/utils/__tests__/metadataValidation.test.ts +++ b/frontend/common/utils/__tests__/metadataValidation.test.ts @@ -1,5 +1,5 @@ import { getGlobalMetadataValidationState } from 'common/utils/metadataValidation' -import { CustomMetadataField } from 'common/hooks/useEntityMetadataFields' +import { CustomMetadataField } from 'common/types/metadata-field' const createField = ( partialField: Partial = {}, diff --git a/frontend/common/utils/mergeMetadataFields.ts b/frontend/common/utils/mergeMetadataFields.ts new file mode 100644 index 000000000000..fc9cf4ac5891 --- /dev/null +++ b/frontend/common/utils/mergeMetadataFields.ts @@ -0,0 +1,60 @@ +import { sortBy } from 'lodash' +import { + Metadata, + MetadataField, + MetadataModelField, + PagedResponse, +} from 'common/types/responses' +import { CustomMetadataField } from 'common/types/metadata-field' + +type EntityWithMetadata = { + metadata?: Metadata[] +} + +/** + * Merges metadata field definitions with model field mappings and existing entity values. + */ +export function mergeMetadataFields( + fieldList: PagedResponse, + modelFieldList: PagedResponse, + entityData: EntityWithMetadata | null, + entityContentType: number, +): CustomMetadataField[] { + // Filter fields that apply to this content type + const fieldsForContentType: CustomMetadataField[] = fieldList.results + .filter((meta) => + modelFieldList.results.some( + (item) => + item.field === meta.id && item.content_type === entityContentType, + ), + ) + .map((meta) => { + const matchingItem = modelFieldList.results.find( + (item) => + item.field === meta.id && item.content_type === entityContentType, + ) + return { + ...meta, + isRequiredFor: !!matchingItem?.is_required_for.length, + metadataModelFieldId: matchingItem ? matchingItem.id : null, + } + }) + + // Get existing values from the entity + const existingValues: Metadata[] = entityData?.metadata ?? [] + + // Merge field definitions with existing values + const mergedMetadata = fieldsForContentType.map((field) => { + const existingValue = existingValues.find( + (v) => v.model_field === field.metadataModelFieldId, + ) + return { + ...field, + field_value: existingValue?.field_value ?? '', + hasValue: !!existingValue, + } + }) + + // Sort required fields first + return sortBy(mergedMetadata, (m) => (m.isRequiredFor ? -1 : 1)) +} diff --git a/frontend/common/utils/metadataValidation.ts b/frontend/common/utils/metadataValidation.ts index e84dc3522c4d..d66e9c4be57c 100644 --- a/frontend/common/utils/metadataValidation.ts +++ b/frontend/common/utils/metadataValidation.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { CustomMetadataField } from 'common/hooks/useEntityMetadataFields' +import { CustomMetadataField } from 'common/types/metadata-field' export type MetadataValidationState = { hasUnfilledRequired: boolean diff --git a/frontend/web/components/metadata/AddMetadataToEntity.tsx b/frontend/web/components/metadata/AddMetadataToEntity.tsx index c78222de0d6b..47a8cc63ff07 100644 --- a/frontend/web/components/metadata/AddMetadataToEntity.tsx +++ b/frontend/web/components/metadata/AddMetadataToEntity.tsx @@ -6,14 +6,10 @@ import { Metadata } from 'common/types/responses' import Utils from 'common/utils/utils' import Switch from 'components/Switch' import InputGroup from 'components/base/forms/InputGroup' -import { - useEntityMetadataFields, - CustomMetadataField, -} from 'common/hooks/useEntityMetadataFields' +import { useGetEntityMetadataFieldsQuery } from 'common/services/useMetadataField' +import { CustomMetadataField } from 'common/types/metadata-field' import { useGlobalMetadataValidation } from 'common/utils/metadataValidation' -export type { CustomMetadataField } - type AddMetadataToEntityProps = { isCloningEnvironment?: boolean organisationId: number @@ -62,13 +58,14 @@ const AddMetadataToEntity: FC = ({ projectId, setHasMetadataRequired, }) => { - const { isLoading, metadataFields: initialFields } = useEntityMetadataFields({ - entityContentType, - entityId: entityId, - entityType: entity as 'feature' | 'segment' | 'environment', - organisationId, - projectId, - }) + const { data: initialFields = [], isLoading } = + useGetEntityMetadataFieldsQuery({ + entityContentType, + entityId, + entityType: entity as 'feature' | 'segment' | 'environment', + organisationId, + projectId, + }) const [metadataFields, setMetadataFields] = useState( [], @@ -170,7 +167,9 @@ const AddMetadataToEntity: FC = ({ @@ -209,31 +238,8 @@ const CreateEnvironmentPage: React.FC = () => { )} - ) : ( -
-

- Check your project permissions -

-

- Although you have been invited to this project, you are not - invited to any environments yet! -

-

- Contact your project administrator asking them to either: -

    -
  • - Invite you to an environment (e.g. develop) by visiting{' '} - Environment settings -
  • -
  • - Grant permissions to create an environment under{' '} - Project settings. -
  • -
-

-
) - } + }} ) From 6f8c33f35c9fa73eafb94fe6614e13cfbe0b10b0 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 16 Feb 2026 10:31:36 +0100 Subject: [PATCH 05/21] feat: resolved-review-comments --- frontend/common/services/useMetadataField.ts | 1 - frontend/common/utils/__tests__/metadataValidation.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/common/services/useMetadataField.ts b/frontend/common/services/useMetadataField.ts index c089ee1494a2..0114669454a2 100644 --- a/frontend/common/services/useMetadataField.ts +++ b/frontend/common/services/useMetadataField.ts @@ -27,7 +27,6 @@ type EntityData = ProjectFlag | Segment | Environment function getEntityUrl(params: EntityMetadataParams): string | null { const { entityId, entityType, projectId } = params - if (!entityId) return null switch (entityType) { case 'feature': diff --git a/frontend/common/utils/__tests__/metadataValidation.test.ts b/frontend/common/utils/__tests__/metadataValidation.test.ts index 3e2300f15a02..860bd86df82c 100644 --- a/frontend/common/utils/__tests__/metadataValidation.test.ts +++ b/frontend/common/utils/__tests__/metadataValidation.test.ts @@ -16,7 +16,7 @@ const createField = ( ...partialField, }) -describe('getMetadataValidationState', () => { +describe('getGlobalMetadataValidationState', () => { it('returns all zeros for empty fields array', () => { const result = getGlobalMetadataValidationState([]) From 79771ed3788f49a075f8895b9eb396957dd305a1 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 16 Feb 2026 18:28:39 +0100 Subject: [PATCH 06/21] feat: added-project-support-for-custom-fields --- api/conftest.py | 5 + api/environments/serializers.py | 4 +- api/features/serializers.py | 4 +- .../0002_add_project_to_metadata_field.py | 46 ++++ api/metadata/models.py | 16 +- api/metadata/permissions.py | 12 +- api/metadata/serializers.py | 71 +++++- api/metadata/views.py | 7 + api/segments/serializers.py | 4 +- .../test_unit_environments_views.py | 36 ++- .../unit/features/test_unit_features_views.py | 38 ++- api/tests/unit/metadata/test_serializers.py | 123 +++++++++- api/tests/unit/metadata/test_views.py | 224 ++++++++++++++++++ .../unit/segments/test_unit_segments_views.py | 43 +++- 14 files changed, 620 insertions(+), 13 deletions(-) create mode 100644 api/metadata/migrations/0002_add_project_to_metadata_field.py diff --git a/api/conftest.py b/api/conftest.py index 13f72607cf2b..0d0b8efa9109 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -371,6 +371,11 @@ def project(organisation): # type: ignore[no-untyped-def] return Project.objects.create(name="Test Project", organisation=organisation) +@pytest.fixture() +def project_b(organisation: Organisation) -> Project: + return Project.objects.create(name="Test Project B", organisation=organisation) # type: ignore[no-any-return] + + @pytest.fixture() def segment(project: Project) -> Segment: segment: Segment = Segment.objects.create(name="segment", project=project) diff --git a/api/environments/serializers.py b/api/environments/serializers.py index 408f3c575c02..c6a3ae8724b8 100644 --- a/api/environments/serializers.py +++ b/api/environments/serializers.py @@ -88,7 +88,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: attrs = super().validate(attrs) project = self.instance.project if self.instance else attrs["project"] # type: ignore[union-attr] organisation = project.organisation - self._validate_required_metadata(organisation, attrs.get("metadata", [])) + self._validate_required_metadata( + organisation, attrs.get("metadata", []), project=project + ) return attrs def create(self, validated_data: dict[str, Any]) -> Environment: diff --git a/api/features/serializers.py b/api/features/serializers.py index 134181afe3a0..2607560e7ac5 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -359,7 +359,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: attrs = super().validate(attrs) project = self.instance.project if self.instance else self.context["project"] # type: ignore[union-attr] organisation = project.organisation - self._validate_required_metadata(organisation, attrs.get("metadata", [])) + self._validate_required_metadata( + organisation, attrs.get("metadata", []), project=project + ) return attrs def create(self, validated_data: dict[str, Any]) -> Feature: diff --git a/api/metadata/migrations/0002_add_project_to_metadata_field.py b/api/metadata/migrations/0002_add_project_to_metadata_field.py new file mode 100644 index 000000000000..d14651304655 --- /dev/null +++ b/api/metadata/migrations/0002_add_project_to_metadata_field.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.11 on 2026-02-16 10:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("metadata", "0001_initial"), + ("organisations", "0058_update_audit_and_history_limits_in_sub_cache"), + ("projects", "0027_add_create_project_level_change_requests_permission"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="metadatafield", + unique_together=set(), + ), + migrations.AddField( + model_name="metadatafield", + name="project", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="projects.project", + ), + ), + migrations.AddConstraint( + model_name="metadatafield", + constraint=models.UniqueConstraint( + condition=models.Q(("project__isnull", True)), + fields=("name", "organisation"), + name="unique_org_level_metadata_field", + ), + ), + migrations.AddConstraint( + model_name="metadatafield", + constraint=models.UniqueConstraint( + condition=models.Q(("project__isnull", False)), + fields=("name", "organisation", "project"), + name="unique_project_level_metadata_field", + ), + ), + ] diff --git a/api/metadata/models.py b/api/metadata/models.py index 4e134731efae..9f69de8e70bb 100644 --- a/api/metadata/models.py +++ b/api/metadata/models.py @@ -36,6 +36,9 @@ class MetadataField(AbstractBaseExportableModel): ) description = models.TextField(blank=True, null=True) organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE) + project = models.ForeignKey( + "projects.Project", on_delete=models.CASCADE, null=True, blank=True + ) def is_field_value_valid(self, field_value: str) -> bool: if len(field_value) > FIELD_VALUE_MAX_LENGTH: @@ -68,7 +71,18 @@ def validate_multiline_str(self, field_value: str): # type: ignore[no-untyped-d return True class Meta: - unique_together = ("name", "organisation") + constraints = [ + models.UniqueConstraint( + fields=["name", "organisation"], + condition=models.Q(project__isnull=True), + name="unique_org_level_metadata_field", + ), + models.UniqueConstraint( + fields=["name", "organisation", "project"], + condition=models.Q(project__isnull=False), + name="unique_project_level_metadata_field", + ), + ] class MetadataModelField(AbstractBaseExportableModel): diff --git a/api/metadata/permissions.py b/api/metadata/permissions.py index c287a8ab9165..194b567a1900 100644 --- a/api/metadata/permissions.py +++ b/api/metadata/permissions.py @@ -4,6 +4,7 @@ from metadata.models import MetadataField from organisations.models import Organisation +from projects.models import Project class MetadataFieldPermissions(IsAuthenticated): @@ -19,7 +20,16 @@ def has_permission(self, request, view): # type: ignore[no-untyped-def] with suppress(Organisation.DoesNotExist): organisation_id = request.data.get("organisation") organisation = Organisation.objects.get(id=organisation_id) - return request.user.is_organisation_admin(organisation) + + if request.user.is_organisation_admin(organisation): + return True + + project_id = request.data.get("project") + if project_id is not None: + with suppress(Project.DoesNotExist): + project = Project.objects.get(id=project_id) + if project.organisation_id == organisation.id: + return request.user.is_project_admin(project) return False diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 8cfca2adb5b4..e89f43990060 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.db.models import Model +from django.db.models import Model, Q from rest_framework import serializers from metadata.models import ( @@ -13,6 +13,7 @@ MetadataModelFieldRequirement, ) from organisations.models import Organisation +from projects.models import Project from util.drf_writable_nested.serializers import ( DeleteBeforeUpdateWritableNestedModelSerializer, ) @@ -22,6 +23,9 @@ class MetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type organisation = serializers.IntegerField( required=True, help_text="Organisation ID to filter by" ) + project = serializers.IntegerField( + required=False, help_text="Project ID to filter by" + ) class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: ignore[type-arg] @@ -29,9 +33,45 @@ class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: class MetadataFieldSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + project = serializers.IntegerField( + required=False, allow_null=True, default=None, source="project_id" + ) + class Meta: model = MetadataField - fields = ("id", "name", "type", "description", "organisation") + fields = ("id", "name", "type", "description", "organisation", "project") + # Disable auto-generated unique validators — conditional + # UniqueConstraints are enforced at the database level. + validators: list[object] = [] + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + data = super().validate(data) + project_id = data.get("project_id") + organisation = data.get("organisation") + + if project_id is not None: + if not Project.objects.filter( + id=project_id, organisation=organisation + ).exists(): + raise serializers.ValidationError( + {"project": "Project must belong to the specified organisation."} + ) + + # Replicate uniqueness checks that DRF can't auto-generate + # from conditional UniqueConstraints. + qs = MetadataField.objects.filter( + name=data.get("name"), + organisation=organisation, + project_id=project_id, + ) + if self.instance is not None: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise serializers.ValidationError( + {"name": "A metadata field with this name already exists."} + ) + + return data class MetadataModelFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg] @@ -109,12 +149,35 @@ class MetadataSerializerMixin: """ def _validate_required_metadata( - self, organisation: Organisation, metadata: list[dict[str, Any]] + self, + organisation: Organisation, + metadata: list[dict[str, Any]], + project: Project | None = None, ) -> None: content_type = ContentType.objects.get_for_model(self.Meta.model) # type: ignore[attr-defined] - requirements = MetadataModelFieldRequirement.objects.filter( + org_ct = ContentType.objects.get_for_model(Organisation) + + # Field scoping: org-level fields + this project's fields + field_scope = Q( model_field__content_type=content_type, model_field__field__organisation=organisation, + model_field__field__project__isnull=True, + ) + if project is not None: + field_scope |= Q( + model_field__content_type=content_type, + model_field__field__organisation=organisation, + model_field__field__project=project, + ) + + # Requirement scoping: org-level + this project's requirements + req_scope = Q(content_type=org_ct, object_id=organisation.id) + if project is not None: + project_ct = ContentType.objects.get_for_model(Project) + req_scope |= Q(content_type=project_ct, object_id=project.id) + + requirements = MetadataModelFieldRequirement.objects.filter( + field_scope & req_scope, ).select_related("model_field__field") metadata_fields = {field["model_field"] for field in metadata} diff --git a/api/metadata/views.py b/api/metadata/views.py index 4600989f90f8..0ec72d39ef99 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -1,6 +1,7 @@ from itertools import chain from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets @@ -49,6 +50,12 @@ def get_queryset(self): # type: ignore[no-untyped-def] raise ValidationError("organisation parameter is required") queryset = queryset.filter(organisation__id=organisation_id) + project_id = serializer.validated_data.get("project") + if project_id is not None: + queryset = queryset.filter( + Q(project__isnull=True) | Q(project_id=project_id) + ) + return queryset diff --git a/api/segments/serializers.py b/api/segments/serializers.py index 4cbc203894f4..2bf44a4778e1 100644 --- a/api/segments/serializers.py +++ b/api/segments/serializers.py @@ -118,7 +118,9 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: project = self.instance.project if self.instance else attrs["project"] # type: ignore[union-attr] organisation = project.organisation - self._validate_required_metadata(organisation, attrs.get("metadata", [])) + self._validate_required_metadata( + organisation, attrs.get("metadata", []), project=project + ) self._validate_segment_rules_conditions_limit(attrs["rules"]) self._validate_project_segment_limit(project) return attrs diff --git a/api/tests/unit/environments/test_unit_environments_views.py b/api/tests/unit/environments/test_unit_environments_views.py index 16537fa6efcf..5db43d2557d2 100644 --- a/api/tests/unit/environments/test_unit_environments_views.py +++ b/api/tests/unit/environments/test_unit_environments_views.py @@ -24,7 +24,12 @@ from environments.permissions.models import UserEnvironmentPermission from features.models import Feature, FeatureState from features.versioning.models import EnvironmentFeatureVersion -from metadata.models import Metadata, MetadataModelField +from metadata.models import ( + Metadata, + MetadataField, + MetadataModelField, + MetadataModelFieldRequirement, +) from organisations.models import Organisation from permissions.models import PermissionModel from projects.models import Project, UserProjectPermission @@ -1325,3 +1330,32 @@ def test_total_segment_overrides_correctly_ignores_old_versions( # Then assert response.json()["total_segment_overrides"] == 1 + + +def test_create_environment__required_metadata_on_other_project__returns_201( + admin_client: APIClient, + project: Project, + project_b: Project, + organisation: Organisation, + a_metadata_field: MetadataField, + environment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project_b + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=environment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project_b.id, + model_field=model_field, + ) + url = reverse("api-v1:environments:environment-list") + data = {"name": "New env", "project": project.id} + + # When - creating an environment in project (not project_b) + response = admin_client.post(url, data=data) + + # Then - should succeed because the requirement is on project_b, not project + assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 05abbecf7562..574b16ff8f15 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -16,6 +16,7 @@ VIEW_PROJECT, ) from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.forms import model_to_dict from django.urls import reverse from django.utils import timezone @@ -49,7 +50,11 @@ from features.multivariate.models import MultivariateFeatureOption from features.value_types import STRING from features.versioning.models import EnvironmentFeatureVersion -from metadata.models import MetadataModelField +from metadata.models import ( + MetadataField, + MetadataModelField, + MetadataModelFieldRequirement, +) from organisations.models import Organisation, OrganisationRole from permissions.models import PermissionModel from projects.code_references.models import FeatureFlagCodeReferencesScan @@ -4240,3 +4245,34 @@ def test_create_multiple_features_with_metadata_keeps_metadata_isolated( second_feature_metadata_after = second_feature_check.json()["metadata"] assert len(second_feature_metadata_after) == 1 assert second_feature_metadata_after[0]["field_value"] == "200" + + +def test_create_feature__required_metadata_on_other_project__returns_201( + admin_client: APIClient, + project: Project, + project_b: Project, + organisation: Organisation, + a_metadata_field: MetadataField, + feature_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project_b + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=feature_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project_b.id, + model_field=model_field, + ) + url = reverse("api-v1:projects:project-features-list", args=[project.id]) + data = {"name": "Test feature cross project", "description": "desc"} + + # When - creating a feature in project (not project_b) + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then - should succeed because the requirement is on project_b, not project + assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/unit/metadata/test_serializers.py b/api/tests/unit/metadata/test_serializers.py index 8e2e5059d24c..4db0bc36c160 100644 --- a/api/tests/unit/metadata/test_serializers.py +++ b/api/tests/unit/metadata/test_serializers.py @@ -2,15 +2,22 @@ import pytest from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers from metadata.models import ( FIELD_VALUE_MAX_LENGTH, MetadataField, MetadataModelField, + MetadataModelFieldRequirement, +) +from metadata.serializers import ( + MetaDataModelFieldSerializer, + MetadataSerializer, + MetadataSerializerMixin, ) -from metadata.serializers import MetaDataModelFieldSerializer, MetadataSerializer from organisations.models import Organisation from projects.models import Project +from segments.models import Segment @pytest.mark.parametrize( @@ -168,3 +175,117 @@ def test_metadata_model_field_serializer_validation_invalid_content_type( serializer.errors["non_field_errors"][0] == "The requirement content type must be project or organisation" ) + + +class _DummySegmentSerializer(MetadataSerializerMixin, serializers.Serializer): # type: ignore[type-arg] + """A minimal serializer for testing _validate_required_metadata with Segment model.""" + + class Meta: + model = Segment + + +def test_validate_required_metadata__requirement_on_project_a__not_enforced_in_project_b( + organisation: Organisation, + project: Project, + project_b: Project, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project A + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project.id, + model_field=model_field, + ) + serializer = _DummySegmentSerializer() + + # When / Then - no error when validating for project B (no metadata provided) + serializer._validate_required_metadata(organisation, [], project=project_b) + + +def test_validate_required_metadata__requirement_on_project_a__enforced_in_project_a( + organisation: Organisation, + project: Project, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project A + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project.id, + model_field=model_field, + ) + serializer = _DummySegmentSerializer() + + # When / Then - error when validating for project A without required metadata + with pytest.raises(serializers.ValidationError, match="Missing required metadata"): + serializer._validate_required_metadata(organisation, [], project=project) + + +def test_validate_required_metadata__org_level_requirement__enforced_in_all_projects( + organisation: Organisation, + project: Project, + project_b: Project, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + organisation_content_type: ContentType, +) -> None: + # Given - an org-level required metadata field + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=organisation_content_type, + object_id=organisation.id, + model_field=model_field, + ) + serializer = _DummySegmentSerializer() + + # When / Then - error in project A + with pytest.raises(serializers.ValidationError, match="Missing required metadata"): + serializer._validate_required_metadata(organisation, [], project=project) + + # When / Then - also error in project B + with pytest.raises(serializers.ValidationError, match="Missing required metadata"): + serializer._validate_required_metadata(organisation, [], project=project_b) + + +def test_validate_required_metadata__project_level_field_with_requirement__only_enforced_in_its_project( + organisation: Organisation, + project: Project, + project_b: Project, + segment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a project-scoped metadata field required for project A + project_field = MetadataField.objects.create( + name="proj_field", type="str", organisation=organisation, project=project + ) + model_field = MetadataModelField.objects.create( + field=project_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project.id, + model_field=model_field, + ) + serializer = _DummySegmentSerializer() + + # When / Then - enforced in project A + with pytest.raises(serializers.ValidationError, match="Missing required metadata"): + serializer._validate_required_metadata(organisation, [], project=project) + + # When / Then - NOT enforced in project B + serializer._validate_required_metadata(organisation, [], project=project_b) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index b9fde12eb8e3..a2fcf227880a 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -1,6 +1,7 @@ import json from itertools import chain +import pytest from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status @@ -14,6 +15,7 @@ from metadata.views import SUPPORTED_REQUIREMENTS_MAPPING # type: ignore[attr-defined] from organisations.models import Organisation from projects.models import Project +from users.models import FFAdminUser def test_can_create_metadata_field(admin_client, organisation): # type: ignore[no-untyped-def] @@ -439,3 +441,225 @@ def test_get_supported_required_for_models(admin_client, organisation): # type: assert len(response.json()) == 2 assert response.json()[0]["model"] == "organisation" assert response.json()[1]["model"] == "project" + + +def test_create_metadata_field__with_project__returns_201( + admin_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + url = reverse("api-v1:metadata:metadata-fields-list") + data = { + "name": "project_field", + "type": "str", + "organisation": organisation.id, + "project": project.id, + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["project"] == project.id + assert response.json()["organisation"] == organisation.id + + +def test_create_metadata_field__project_from_different_org__returns_400( + admin_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + other_org = Organisation.objects.create(name="Other Org") + other_project = Project.objects.create(name="Other Project", organisation=other_org) + url = reverse("api-v1:metadata:metadata-fields-list") + data = { + "name": "bad_field", + "type": "str", + "organisation": organisation.id, + "project": other_project.id, + } + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_metadata_field__project_admin__returns_201( + staff_user: FFAdminUser, + staff_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + from projects.models import UserProjectPermission + + UserProjectPermission.objects.create(user=staff_user, project=project, admin=True) + + url = reverse("api-v1:metadata:metadata-fields-list") + data = { + "name": "project_admin_field", + "type": "str", + "organisation": organisation.id, + "project": project.id, + } + + # When + response = staff_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + + +def test_list_metadata_fields__filter_by_project__returns_org_and_project_fields( + admin_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="org_field", type="str", organisation=organisation + ) + project_field = MetadataField.objects.create( + name="project_field", type="str", organisation=organisation, project=project + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}&project={project.id}" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert org_field.id in returned_ids + assert project_field.id in returned_ids + + +def test_list_metadata_fields__filter_by_project__excludes_other_project_fields( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="org_field", type="str", organisation=organisation + ) + project_a_field = MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + project_b_field = MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}&project={project.id}" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert org_field.id in returned_ids + assert project_a_field.id in returned_ids + assert project_b_field.id not in returned_ids + + +def test_list_metadata_fields__no_project_filter__returns_all( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="org_field", type="str", organisation=organisation + ) + project_a_field = MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + project_b_field = MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert org_field.id in returned_ids + assert project_a_field.id in returned_ids + assert project_b_field.id in returned_ids + + +@pytest.mark.parametrize( + "existing_project_attr, new_project_attr, expected_status", + [ + (None, None, status.HTTP_400_BAD_REQUEST), + ("project", "project", status.HTTP_400_BAD_REQUEST), + ("project", "project_b", status.HTTP_201_CREATED), + (None, "project", status.HTTP_201_CREATED), + ], + ids=[ + "duplicate_org_level", + "duplicate_project_level", + "same_name_different_projects", + "same_name_org_and_project_level", + ], +) +def test_create_metadata_field__uniqueness( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, + existing_project_attr: str | None, + new_project_attr: str | None, + expected_status: int, + request: pytest.FixtureRequest, +) -> None: + # Given - a field already exists + existing_project = ( + request.getfixturevalue(existing_project_attr) + if existing_project_attr + else None + ) + MetadataField.objects.create( + name="the_field", + type="str", + organisation=organisation, + project=existing_project, + ) + + new_project = ( + request.getfixturevalue(new_project_attr) if new_project_attr else None + ) + url = reverse("api-v1:metadata:metadata-fields-list") + data: dict[str, object] = { + "name": "the_field", + "type": "str", + "organisation": organisation.id, + } + if new_project is not None: + data["project"] = new_project.id + + # When + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == expected_status diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 57cc212884ed..7add85bfbf49 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -24,7 +24,13 @@ from environments.models import Environment from features.models import Feature, FeatureSegment, FeatureState from features.versioning.models import EnvironmentFeatureVersion -from metadata.models import Metadata, MetadataModelField +from metadata.models import ( + Metadata, + MetadataField, + MetadataModelField, + MetadataModelFieldRequirement, +) +from organisations.models import Organisation from projects.models import Project from segments.models import Condition, Segment, SegmentRule, WhitelistedSegment from tests.types import WithProjectPermissionsCallable @@ -1842,3 +1848,38 @@ def test_clone_segment_without_name_should_fail( # Then assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_segment__required_metadata_on_other_project__returns_201( + admin_client: APIClient, + project: Project, + project_b: Project, + organisation: Organisation, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + project_content_type: ContentType, +) -> None: + # Given - a required metadata field scoped to project_b + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project_b.id, + model_field=model_field, + ) + url = reverse("api-v1:projects:project-segments-list", args=[project.id]) + data = { + "name": "Test segment cross project", + "project": project.id, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], + } + + # When - creating a segment in project (not project_b) + response = admin_client.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then - should succeed because the requirement is on project_b, not project + assert response.status_code == status.HTTP_201_CREATED From 2c39ce8df319cfeb32354cfb7c033f5e140b500a Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 09:55:17 +0100 Subject: [PATCH 07/21] feat: reworked-comments --- .../test_unit_environments_views.py | 6 +++--- .../unit/features/test_unit_features_views.py | 6 +++--- api/tests/unit/metadata/test_serializers.py | 18 ++++++++---------- api/tests/unit/metadata/test_views.py | 2 +- .../unit/segments/test_unit_segments_views.py | 6 +++--- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/api/tests/unit/environments/test_unit_environments_views.py b/api/tests/unit/environments/test_unit_environments_views.py index 5db43d2557d2..88e5efd86b33 100644 --- a/api/tests/unit/environments/test_unit_environments_views.py +++ b/api/tests/unit/environments/test_unit_environments_views.py @@ -1341,7 +1341,7 @@ def test_create_environment__required_metadata_on_other_project__returns_201( environment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project_b + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=environment_content_type, @@ -1354,8 +1354,8 @@ def test_create_environment__required_metadata_on_other_project__returns_201( url = reverse("api-v1:environments:environment-list") data = {"name": "New env", "project": project.id} - # When - creating an environment in project (not project_b) + # When response = admin_client.post(url, data=data) - # Then - should succeed because the requirement is on project_b, not project + # Then assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 574b16ff8f15..7097dcab33a0 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -4256,7 +4256,7 @@ def test_create_feature__required_metadata_on_other_project__returns_201( feature_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project_b + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=feature_content_type, @@ -4269,10 +4269,10 @@ def test_create_feature__required_metadata_on_other_project__returns_201( url = reverse("api-v1:projects:project-features-list", args=[project.id]) data = {"name": "Test feature cross project", "description": "desc"} - # When - creating a feature in project (not project_b) + # When response = admin_client.post( url, data=json.dumps(data), content_type="application/json" ) - # Then - should succeed because the requirement is on project_b, not project + # Then assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/unit/metadata/test_serializers.py b/api/tests/unit/metadata/test_serializers.py index 4db0bc36c160..dbe493038103 100644 --- a/api/tests/unit/metadata/test_serializers.py +++ b/api/tests/unit/metadata/test_serializers.py @@ -192,7 +192,7 @@ def test_validate_required_metadata__requirement_on_project_a__not_enforced_in_p segment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project A + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=segment_content_type, @@ -204,7 +204,7 @@ def test_validate_required_metadata__requirement_on_project_a__not_enforced_in_p ) serializer = _DummySegmentSerializer() - # When / Then - no error when validating for project B (no metadata provided) + # When / Then serializer._validate_required_metadata(organisation, [], project=project_b) @@ -215,7 +215,7 @@ def test_validate_required_metadata__requirement_on_project_a__enforced_in_proje segment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project A + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=segment_content_type, @@ -227,7 +227,7 @@ def test_validate_required_metadata__requirement_on_project_a__enforced_in_proje ) serializer = _DummySegmentSerializer() - # When / Then - error when validating for project A without required metadata + # When / Then with pytest.raises(serializers.ValidationError, match="Missing required metadata"): serializer._validate_required_metadata(organisation, [], project=project) @@ -240,7 +240,7 @@ def test_validate_required_metadata__org_level_requirement__enforced_in_all_proj segment_content_type: ContentType, organisation_content_type: ContentType, ) -> None: - # Given - an org-level required metadata field + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=segment_content_type, @@ -252,11 +252,10 @@ def test_validate_required_metadata__org_level_requirement__enforced_in_all_proj ) serializer = _DummySegmentSerializer() - # When / Then - error in project A + # When / Then with pytest.raises(serializers.ValidationError, match="Missing required metadata"): serializer._validate_required_metadata(organisation, [], project=project) - # When / Then - also error in project B with pytest.raises(serializers.ValidationError, match="Missing required metadata"): serializer._validate_required_metadata(organisation, [], project=project_b) @@ -268,7 +267,7 @@ def test_validate_required_metadata__project_level_field_with_requirement__only_ segment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a project-scoped metadata field required for project A + # Given project_field = MetadataField.objects.create( name="proj_field", type="str", organisation=organisation, project=project ) @@ -283,9 +282,8 @@ def test_validate_required_metadata__project_level_field_with_requirement__only_ ) serializer = _DummySegmentSerializer() - # When / Then - enforced in project A + # When / Then with pytest.raises(serializers.ValidationError, match="Missing required metadata"): serializer._validate_required_metadata(organisation, [], project=project) - # When / Then - NOT enforced in project B serializer._validate_required_metadata(organisation, [], project=project_b) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index a2fcf227880a..5b6cda242588 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -631,7 +631,7 @@ def test_create_metadata_field__uniqueness( expected_status: int, request: pytest.FixtureRequest, ) -> None: - # Given - a field already exists + # Given existing_project = ( request.getfixturevalue(existing_project_attr) if existing_project_attr diff --git a/api/tests/unit/segments/test_unit_segments_views.py b/api/tests/unit/segments/test_unit_segments_views.py index 7add85bfbf49..5dfd501f9406 100644 --- a/api/tests/unit/segments/test_unit_segments_views.py +++ b/api/tests/unit/segments/test_unit_segments_views.py @@ -1859,7 +1859,7 @@ def test_create_segment__required_metadata_on_other_project__returns_201( segment_content_type: ContentType, project_content_type: ContentType, ) -> None: - # Given - a required metadata field scoped to project_b + # Given model_field = MetadataModelField.objects.create( field=a_metadata_field, content_type=segment_content_type, @@ -1876,10 +1876,10 @@ def test_create_segment__required_metadata_on_other_project__returns_201( "rules": [{"type": "ALL", "rules": [], "conditions": []}], } - # When - creating a segment in project (not project_b) + # When response = admin_client.post( url, data=json.dumps(data), content_type="application/json" ) - # Then - should succeed because the requirement is on project_b, not project + # Then assert response.status_code == status.HTTP_201_CREATED From 705760906ba9274b5299d1f312067654cfc158af Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 10:37:58 +0100 Subject: [PATCH 08/21] feat: project-custom-fields-override-org-ones --- api/metadata/serializers.py | 14 ++++++++-- api/metadata/views.py | 6 ++++- api/tests/unit/metadata/test_serializers.py | 30 +++++++++++++++++++++ api/tests/unit/metadata/test_views.py | 25 +++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index e89f43990060..221d46f8d2d5 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -180,12 +180,22 @@ def _validate_required_metadata( field_scope & req_scope, ).select_related("model_field__field") + overridden_names: set[str] = set() + if project is not None: + overridden_names = set( + MetadataField.objects.filter( + organisation=organisation, project=project + ).values_list("name", flat=True) + ) + metadata_fields = {field["model_field"] for field in metadata} for requirement in requirements: + field = requirement.model_field.field + if field.project is None and field.name in overridden_names: + continue if requirement.model_field not in metadata_fields: - field_name = requirement.model_field.field.name raise serializers.ValidationError( - {"metadata": f"Missing required metadata field: {field_name}"} + {"metadata": f"Missing required metadata field: {field.name}"} ) def _update_metadata( diff --git a/api/metadata/views.py b/api/metadata/views.py index 0ec72d39ef99..5cbd5dffa641 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -52,9 +52,13 @@ def get_queryset(self): # type: ignore[no-untyped-def] project_id = serializer.validated_data.get("project") if project_id is not None: + overridden_names = MetadataField.objects.filter( + organisation_id=organisation_id, + project_id=project_id, + ).values_list("name", flat=True) queryset = queryset.filter( Q(project__isnull=True) | Q(project_id=project_id) - ) + ).exclude(project__isnull=True, name__in=overridden_names) return queryset diff --git a/api/tests/unit/metadata/test_serializers.py b/api/tests/unit/metadata/test_serializers.py index dbe493038103..085fc9a2f13d 100644 --- a/api/tests/unit/metadata/test_serializers.py +++ b/api/tests/unit/metadata/test_serializers.py @@ -287,3 +287,33 @@ def test_validate_required_metadata__project_level_field_with_requirement__only_ serializer._validate_required_metadata(organisation, [], project=project) serializer._validate_required_metadata(organisation, [], project=project_b) + + +@pytest.mark.django_db +def test_validate_required_metadata__project_field_overrides_required_org_field__org_requirement_skipped( + organisation: Organisation, + project: Project, + a_metadata_field: MetadataField, + segment_content_type: ContentType, + organisation_content_type: ContentType, +) -> None: + # Given + model_field = MetadataModelField.objects.create( + field=a_metadata_field, + content_type=segment_content_type, + ) + MetadataModelFieldRequirement.objects.create( + content_type=organisation_content_type, + object_id=organisation.id, + model_field=model_field, + ) + MetadataField.objects.create( + name=a_metadata_field.name, + type=a_metadata_field.type, + organisation=organisation, + project=project, + ) + serializer = _DummySegmentSerializer() + + # When / Then + serializer._validate_required_metadata(organisation, [], project=project) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 5b6cda242588..f729c6f8c10a 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -606,6 +606,31 @@ def test_list_metadata_fields__no_project_filter__returns_all( assert project_b_field.id in returned_ids +def test_list_metadata_fields__project_field_overrides_org_field__org_field_excluded( + admin_client: APIClient, + organisation: Organisation, + project: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="shared_name", type="str", organisation=organisation + ) + project_field = MetadataField.objects.create( + name="shared_name", type="int", organisation=organisation, project=project + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}&project={project.id}" + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert project_field.id in returned_ids + assert org_field.id not in returned_ids + + @pytest.mark.parametrize( "existing_project_attr, new_project_attr, expected_status", [ From 2c6cf8b7e7b45b913c486f068b9e48b186c5960b Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 15:42:52 +0100 Subject: [PATCH 09/21] fix: skip-feature-metadata-fetch-when-creating-feature --- frontend/common/services/useMetadataField.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/common/services/useMetadataField.ts b/frontend/common/services/useMetadataField.ts index 0114669454a2..7730df4db555 100644 --- a/frontend/common/services/useMetadataField.ts +++ b/frontend/common/services/useMetadataField.ts @@ -93,7 +93,7 @@ export const metadataService = service ] // Only fetch entity data if we have an entityId - if (entityUrl) { + if (arg.entityId && entityUrl) { queries.push(baseQuery({ url: entityUrl })) } From 463d388d16417f1f65745d26b78cd96d3e2a94ef Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 16:19:08 +0100 Subject: [PATCH 10/21] fix: fixed-parsing-error --- frontend/web/components/modals/create-feature/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/modals/create-feature/index.js b/frontend/web/components/modals/create-feature/index.js index fe52a7a90323..242a25baf2bc 100644 --- a/frontend/web/components/modals/create-feature/index.js +++ b/frontend/web/components/modals/create-feature/index.js @@ -498,7 +498,11 @@ const Index = class extends Component { } parseError = (error) => { const { projectFlag } = this.props - let featureError = error?.message || error?.name?.[0] || error + let featureError = + error?.metadata?.flatMap((m) => m.non_field_errors ?? []).join('\n') || + error?.message || + error?.name?.[0] || + error let featureWarning = '' //Treat multivariate no changes as warnings if ( From 0503ad32263676efa7777702360cb262bc7ed7b0 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 17:53:32 +0100 Subject: [PATCH 11/21] feat: invalidate-tags-on-delete-metadata --- frontend/common/services/useMetadataField.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/common/services/useMetadataField.ts b/frontend/common/services/useMetadataField.ts index 7730df4db555..8304b6836c6a 100644 --- a/frontend/common/services/useMetadataField.ts +++ b/frontend/common/services/useMetadataField.ts @@ -48,7 +48,7 @@ export const metadataService = service Res['metadataField'], Req['createMetadataField'] >({ - invalidatesTags: [{ id: 'LIST', type: 'Metadata' }], + invalidatesTags: [{ type: 'Metadata' }], query: (query: Req['createMetadataField']) => ({ body: query.body, method: 'POST', @@ -59,7 +59,7 @@ export const metadataService = service Res['metadataField'], Req['deleteMetadataField'] >({ - invalidatesTags: [{ id: 'LIST', type: 'Metadata' }], + invalidatesTags: [{ type: 'Metadata' }], query: (query: Req['deleteMetadataField']) => ({ method: 'DELETE', url: `metadata/fields/${query.id}/`, @@ -148,10 +148,7 @@ export const metadataService = service Res['metadataField'], Req['updateMetadataField'] >({ - invalidatesTags: (res) => [ - { id: 'LIST', type: 'Metadata' }, - { id: res?.id, type: 'Metadata' }, - ], + invalidatesTags: [{ type: 'Metadata' }], query: (query: Req['updateMetadataField']) => ({ body: query.body, method: 'PUT', From cd7b03c36c508c633c3ef6a84211fc34aaba948a Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 17 Feb 2026 18:42:29 +0100 Subject: [PATCH 12/21] feat: reviewed-query-params-and-filtering --- api/metadata/serializers.py | 8 ++++++- api/metadata/views.py | 2 ++ api/tests/unit/metadata/test_views.py | 32 ++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 221d46f8d2d5..4dd7db454157 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -24,7 +24,13 @@ class MetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type required=True, help_text="Organisation ID to filter by" ) project = serializers.IntegerField( - required=False, help_text="Project ID to filter by" + required=False, + help_text="Project ID. Returns organisation-level fields plus this project's fields.", + ) + include_projects = serializers.BooleanField( + required=False, + default=False, + help_text="Include fields from all projects. Ignored when project is specified.", ) diff --git a/api/metadata/views.py b/api/metadata/views.py index 5cbd5dffa641..0001601c0f37 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -59,6 +59,8 @@ def get_queryset(self): # type: ignore[no-untyped-def] queryset = queryset.filter( Q(project__isnull=True) | Q(project_id=project_id) ).exclude(project__isnull=True, name__in=overridden_names) + elif not serializer.validated_data.get("include_projects"): + queryset = queryset.filter(project__isnull=True) return queryset diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index f729c6f8c10a..3ef99e712054 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -576,7 +576,7 @@ def test_list_metadata_fields__filter_by_project__excludes_other_project_fields( assert project_b_field.id not in returned_ids -def test_list_metadata_fields__no_project_filter__returns_all( +def test_list_metadata_fields__no_project_filter__returns_org_level_only( admin_client: APIClient, organisation: Organisation, project: Project, @@ -598,6 +598,36 @@ def test_list_metadata_fields__no_project_filter__returns_all( # When response = admin_client.get(url) + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert org_field.id in returned_ids + assert project_a_field.id not in returned_ids + assert project_b_field.id not in returned_ids + + +def test_list_metadata_fields__include_projects__returns_all( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, +) -> None: + # Given + org_field = MetadataField.objects.create( + name="org_field", type="str", organisation=organisation + ) + project_a_field = MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + project_b_field = MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b + ) + base_url = reverse("api-v1:metadata:metadata-fields-list") + url = f"{base_url}?organisation={organisation.id}&include_projects=true" + + # When + response = admin_client.get(url) + # Then assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} From 4fea4767c3429f8f223f217f18771075b4ead859 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 18 Feb 2026 10:11:47 +0100 Subject: [PATCH 13/21] feat: fetch-metadata-model-in-api-and-separate-custom-fields-project-endpoint --- api/metadata/serializers.py | 57 ++++++++---- api/metadata/views.py | 65 ++++++++++---- api/projects/urls.py | 6 ++ api/tests/unit/metadata/test_views.py | 125 ++++++++++++++++---------- 4 files changed, 171 insertions(+), 82 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 4dd7db454157..20abe765620a 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -23,14 +23,14 @@ class MetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type organisation = serializers.IntegerField( required=True, help_text="Organisation ID to filter by" ) - project = serializers.IntegerField( - required=False, - help_text="Project ID. Returns organisation-level fields plus this project's fields.", - ) - include_projects = serializers.BooleanField( + + +class ProjectMetadataFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg] + include_organisation = serializers.BooleanField( required=False, default=False, - help_text="Include fields from all projects. Ignored when project is specified.", + help_text="Include inherited organisation-level fields. " + "Project-level fields override same-named org fields.", ) @@ -38,14 +38,45 @@ class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: model_name = serializers.CharField(required=True) +class MetadataModelFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg] + content_type = serializers.IntegerField( + required=False, help_text="Content type of the model to filter by." + ) + + +class MetadataModelFieldRequirementSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + class Meta: + model = MetadataModelFieldRequirement + fields = ("content_type", "object_id") + + +class MetadataModelFieldNestedSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + is_required_for = MetadataModelFieldRequirementSerializer(many=True, read_only=True) + + class Meta: + model = MetadataModelField + fields = ("id", "content_type", "is_required_for") + + class MetadataFieldSerializer(serializers.ModelSerializer): # type: ignore[type-arg] project = serializers.IntegerField( required=False, allow_null=True, default=None, source="project_id" ) + model_fields = MetadataModelFieldNestedSerializer( + source="metadatamodelfield_set", many=True, read_only=True + ) class Meta: model = MetadataField - fields = ("id", "name", "type", "description", "organisation", "project") + fields = ( + "id", + "name", + "type", + "description", + "organisation", + "project", + "model_fields", + ) # Disable auto-generated unique validators — conditional # UniqueConstraints are enforced at the database level. validators: list[object] = [] @@ -80,18 +111,6 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: return data -class MetadataModelFieldQuerySerializer(serializers.Serializer): # type: ignore[type-arg] - content_type = serializers.IntegerField( - required=False, help_text="Content type of the model to filter by." - ) - - -class MetadataModelFieldRequirementSerializer(serializers.ModelSerializer): # type: ignore[type-arg] - class Meta: - model = MetadataModelFieldRequirement - fields = ("content_type", "object_id") - - class MetaDataModelFieldSerializer(DeleteBeforeUpdateWritableNestedModelSerializer): is_required_for = MetadataModelFieldRequirementSerializer(many=True, required=False) diff --git a/api/metadata/views.py b/api/metadata/views.py index 0001601c0f37..91d5ab713cf4 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -1,7 +1,7 @@ from itertools import chain from django.contrib.contenttypes.models import ContentType -from django.db.models import Q +from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets @@ -9,6 +9,9 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response +from projects.models import Project +from projects.permissions import VIEW_PROJECT, NestedProjectPermissions + from .models import ( SUPPORTED_REQUIREMENTS_MAPPING, MetadataField, @@ -24,6 +27,7 @@ MetadataFieldSerializer, MetadataModelFieldQuerySerializer, MetaDataModelFieldSerializer, + ProjectMetadataFieldQuerySerializer, SupportedRequiredForModelQuerySerializer, ) @@ -46,23 +50,12 @@ def get_queryset(self): # type: ignore[no-untyped-def] serializer.is_valid(raise_exception=True) organisation_id = serializer.validated_data["organisation"] - if organisation_id is None: - raise ValidationError("organisation parameter is required") - queryset = queryset.filter(organisation__id=organisation_id) - - project_id = serializer.validated_data.get("project") - if project_id is not None: - overridden_names = MetadataField.objects.filter( - organisation_id=organisation_id, - project_id=project_id, - ).values_list("name", flat=True) - queryset = queryset.filter( - Q(project__isnull=True) | Q(project_id=project_id) - ).exclude(project__isnull=True, name__in=overridden_names) - elif not serializer.validated_data.get("include_projects"): - queryset = queryset.filter(project__isnull=True) + queryset = queryset.filter( + organisation_id=organisation_id, + project__isnull=True, + ) - return queryset + return queryset.prefetch_related("metadatamodelfield_set__is_required_for") class MetaDataModelFieldViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] @@ -126,3 +119,41 @@ def supported_required_for_models(self, request, organisation_pk=None): # type: serializer = ContentTypeSerializer(qs, many=True) # type: ignore[assignment] return Response(serializer.data) + + +@method_decorator( + name="list", + decorator=extend_schema(parameters=[ProjectMetadataFieldQuerySerializer]), +) +class ProjectMetadataFieldViewSet(viewsets.ReadOnlyModelViewSet): # type: ignore[type-arg] + serializer_class = MetadataFieldSerializer + permission_classes = [NestedProjectPermissions] + + def get_queryset(self): # type: ignore[no-untyped-def] + if getattr(self, "swagger_fake_view", False): + return MetadataField.objects.none() + + project = get_object_or_404( + self.request.user.get_permitted_projects(VIEW_PROJECT), + pk=self.kwargs["project_pk"], + ) + + queryset = MetadataField.objects.filter( + organisation=project.organisation, + project_id=project.id, + ) + + serializer = ProjectMetadataFieldQuerySerializer( + data=self.request.query_params + ) + serializer.is_valid(raise_exception=True) + + if serializer.validated_data.get("include_organisation"): + overridden_names = queryset.values_list("name", flat=True) + org_fields = MetadataField.objects.filter( + organisation=project.organisation, + project__isnull=True, + ).exclude(name__in=overridden_names) + queryset = queryset | org_fields + + return queryset.prefetch_related("metadatamodelfield_set__is_required_for") diff --git a/api/projects/urls.py b/api/projects/urls.py index 01f6e70ad3ed..e65b86ffb19f 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -22,6 +22,7 @@ from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet +from metadata.views import ProjectMetadataFieldViewSet from projects.tags.views import TagViewSet from segments.views import SegmentViewSet @@ -84,6 +85,11 @@ FeatureHealthEventViewSet, basename="feature-health-events", ) +projects_router.register( + r"metadata/fields", + ProjectMetadataFieldViewSet, + basename="project-metadata-fields", +) if settings.WORKFLOWS_LOGIC_INSTALLED: # pragma: no cover workflow_views = importlib.import_module("workflows_logic.views") diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 3ef99e712054..30c17275edd3 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -521,20 +521,24 @@ def test_create_metadata_field__project_admin__returns_201( assert response.status_code == status.HTTP_201_CREATED -def test_list_metadata_fields__filter_by_project__returns_org_and_project_fields( +def test_list_metadata_fields__returns_org_level_only( admin_client: APIClient, organisation: Organisation, project: Project, + project_b: Project, ) -> None: # Given org_field = MetadataField.objects.create( name="org_field", type="str", organisation=organisation ) - project_field = MetadataField.objects.create( - name="project_field", type="str", organisation=organisation, project=project + MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b ) base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}&project={project.id}" + url = f"{base_url}?organisation={organisation.id}" # When response = admin_client.get(url) @@ -542,58 +546,69 @@ def test_list_metadata_fields__filter_by_project__returns_org_and_project_fields # Then assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} - assert org_field.id in returned_ids - assert project_field.id in returned_ids + assert returned_ids == {org_field.id} -def test_list_metadata_fields__filter_by_project__excludes_other_project_fields( +def test_list_metadata_fields__response_includes_nested_model_fields( admin_client: APIClient, organisation: Organisation, + environment_content_type: ContentType, + project_content_type: ContentType, project: Project, - project_b: Project, ) -> None: # Given - org_field = MetadataField.objects.create( - name="org_field", type="str", organisation=organisation + field = MetadataField.objects.create( + name="my_field", type="str", organisation=organisation ) - project_a_field = MetadataField.objects.create( - name="proj_a_field", type="str", organisation=organisation, project=project + model_field = MetadataModelField.objects.create( + field=field, content_type=environment_content_type ) - project_b_field = MetadataField.objects.create( - name="proj_b_field", type="str", organisation=organisation, project=project_b + requirement = MetadataModelFieldRequirement.objects.create( + content_type=project_content_type, + object_id=project.id, + model_field=model_field, ) base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}&project={project.id}" + url = f"{base_url}?organisation={organisation.id}" # When response = admin_client.get(url) # Then assert response.status_code == status.HTTP_200_OK - returned_ids = {r["id"] for r in response.json()["results"]} - assert org_field.id in returned_ids - assert project_a_field.id in returned_ids - assert project_b_field.id not in returned_ids - - -def test_list_metadata_fields__no_project_filter__returns_org_level_only( + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["model_fields"] == [ + { + "id": model_field.id, + "content_type": environment_content_type.id, + "is_required_for": [ + { + "content_type": project_content_type.id, + "object_id": project.id, + } + ], + } + ] + + +def test_list_project_metadata_fields__returns_project_fields_only( admin_client: APIClient, organisation: Organisation, project: Project, project_b: Project, ) -> None: # Given - org_field = MetadataField.objects.create( + MetadataField.objects.create( name="org_field", type="str", organisation=organisation ) - project_a_field = MetadataField.objects.create( + project_field = MetadataField.objects.create( name="proj_a_field", type="str", organisation=organisation, project=project ) - project_b_field = MetadataField.objects.create( + MetadataField.objects.create( name="proj_b_field", type="str", organisation=organisation, project=project_b ) - base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}" + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) # When response = admin_client.get(url) @@ -601,42 +616,34 @@ def test_list_metadata_fields__no_project_filter__returns_org_level_only( # Then assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} - assert org_field.id in returned_ids - assert project_a_field.id not in returned_ids - assert project_b_field.id not in returned_ids + assert returned_ids == {project_field.id} -def test_list_metadata_fields__include_projects__returns_all( +def test_list_project_metadata_fields__include_organisation__returns_both( admin_client: APIClient, organisation: Organisation, project: Project, - project_b: Project, ) -> None: # Given org_field = MetadataField.objects.create( name="org_field", type="str", organisation=organisation ) - project_a_field = MetadataField.objects.create( - name="proj_a_field", type="str", organisation=organisation, project=project - ) - project_b_field = MetadataField.objects.create( - name="proj_b_field", type="str", organisation=organisation, project=project_b + project_field = MetadataField.objects.create( + name="project_field", type="str", organisation=organisation, project=project ) - base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}&include_projects=true" + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) # When - response = admin_client.get(url) + response = admin_client.get(f"{url}?include_organisation=true") # Then assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} assert org_field.id in returned_ids - assert project_a_field.id in returned_ids - assert project_b_field.id in returned_ids + assert project_field.id in returned_ids -def test_list_metadata_fields__project_field_overrides_org_field__org_field_excluded( +def test_list_project_metadata_fields__include_organisation__project_overrides_org( admin_client: APIClient, organisation: Organisation, project: Project, @@ -648,11 +655,10 @@ def test_list_metadata_fields__project_field_overrides_org_field__org_field_excl project_field = MetadataField.objects.create( name="shared_name", type="int", organisation=organisation, project=project ) - base_url = reverse("api-v1:metadata:metadata-fields-list") - url = f"{base_url}?organisation={organisation.id}&project={project.id}" + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) # When - response = admin_client.get(url) + response = admin_client.get(f"{url}?include_organisation=true") # Then assert response.status_code == status.HTTP_200_OK @@ -661,6 +667,33 @@ def test_list_metadata_fields__project_field_overrides_org_field__org_field_excl assert org_field.id not in returned_ids +def test_list_project_metadata_fields__excludes_other_project_fields( + admin_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, +) -> None: + # Given + project_a_field = MetadataField.objects.create( + name="proj_a_field", type="str", organisation=organisation, project=project + ) + MetadataField.objects.create( + name="proj_b_field", type="str", organisation=organisation, project=project_b + ) + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When + response = admin_client.get(f"{url}?include_organisation=true") + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert project_a_field.id in returned_ids + assert all( + r["project"] in (project.id, None) for r in response.json()["results"] + ) + + @pytest.mark.parametrize( "existing_project_attr, new_project_attr, expected_status", [ From 790ff64c6e1eeceb351b4d1b6c3f55d67e2857e2 Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 12:56:25 +0100 Subject: [PATCH 14/21] feat: added-pagination-and-filtering-by-entity-to-custom-fields --- api/metadata/serializers.py | 5 + api/metadata/views.py | 15 ++- api/tests/unit/metadata/test_views.py | 161 +++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 8 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 20abe765620a..6a22ade1542e 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -32,6 +32,11 @@ class ProjectMetadataFieldQuerySerializer(serializers.Serializer): # type: igno help_text="Include inherited organisation-level fields. " "Project-level fields override same-named org fields.", ) + entity = serializers.ChoiceField( + required=False, + choices=["feature", "segment", "environment"], + help_text="Filter by entity type (feature, segment, or environment).", + ) class SupportedRequiredForModelQuerySerializer(serializers.Serializer): # type: ignore[type-arg] diff --git a/api/metadata/views.py b/api/metadata/views.py index 91d5ab713cf4..91e232a58e69 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -6,10 +6,9 @@ from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from projects.models import Project +from app.pagination import CustomPagination from projects.permissions import VIEW_PROJECT, NestedProjectPermissions from .models import ( @@ -128,6 +127,7 @@ def supported_required_for_models(self, request, organisation_pk=None): # type: class ProjectMetadataFieldViewSet(viewsets.ReadOnlyModelViewSet): # type: ignore[type-arg] serializer_class = MetadataFieldSerializer permission_classes = [NestedProjectPermissions] + pagination_class = CustomPagination def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): @@ -143,9 +143,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] project_id=project.id, ) - serializer = ProjectMetadataFieldQuerySerializer( - data=self.request.query_params - ) + serializer = ProjectMetadataFieldQuerySerializer(data=self.request.query_params) serializer.is_valid(raise_exception=True) if serializer.validated_data.get("include_organisation"): @@ -156,4 +154,11 @@ def get_queryset(self): # type: ignore[no-untyped-def] ).exclude(name__in=overridden_names) queryset = queryset | org_fields + entity = serializer.validated_data.get("entity") + if entity: + content_type = ContentType.objects.get(model=entity) + queryset = queryset.filter( + metadatamodelfield__content_type=content_type, + ) + return queryset.prefetch_related("metadatamodelfield_set__is_required_for") diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 30c17275edd3..829081c26c42 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -689,9 +689,7 @@ def test_list_project_metadata_fields__excludes_other_project_fields( assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} assert project_a_field.id in returned_ids - assert all( - r["project"] in (project.id, None) for r in response.json()["results"] - ) + assert all(r["project"] in (project.id, None) for r in response.json()["results"]) @pytest.mark.parametrize( @@ -751,3 +749,160 @@ def test_create_metadata_field__uniqueness( # Then assert response.status_code == expected_status + + +def test_list_project_metadata_fields__page_size__returns_all( + admin_client: APIClient, + organisation: Organisation, + project: Project, + feature_content_type: ContentType, +) -> None: + # Given - create 15 fields to exceed the default page size of 10 + fields = [] + for i in range(15): + field = MetadataField.objects.create( + name=f"field_{i}", type="str", organisation=organisation, project=project + ) + MetadataModelField.objects.create( + field=field, content_type=feature_content_type + ) + fields.append(field) + + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When + response = admin_client.get(f"{url}?page_size=100") + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 15 + + +@pytest.mark.parametrize( + "entity", + ["feature", "segment", "environment"], +) +def test_list_project_metadata_fields__entity_filter__returns_matching_only( + admin_client: APIClient, + organisation: Organisation, + project: Project, + feature_content_type: ContentType, + segment_content_type: ContentType, + environment_content_type: ContentType, + entity: str, +) -> None: + # Given + content_types = { + "feature": feature_content_type, + "segment": segment_content_type, + "environment": environment_content_type, + } + + created_fields: dict[str, MetadataField] = {} + for name, ct in content_types.items(): + field = MetadataField.objects.create( + name=f"{name}_only_field", + type="str", + organisation=organisation, + project=project, + ) + MetadataModelField.objects.create(field=field, content_type=ct) + created_fields[name] = field + + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When + response = admin_client.get(f"{url}?entity={entity}") + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert returned_ids == {created_fields[entity].id} + + +def test_list_project_metadata_fields__entity_filter__includes_multi_entity_fields( + admin_client: APIClient, + organisation: Organisation, + project: Project, + feature_content_type: ContentType, + segment_content_type: ContentType, + environment_content_type: ContentType, +) -> None: + # Given - a field assigned to all three entities + all_entities_field = MetadataField.objects.create( + name="all_entities_field", + type="str", + organisation=organisation, + project=project, + ) + for ct in [feature_content_type, segment_content_type, environment_content_type]: + MetadataModelField.objects.create(field=all_entities_field, content_type=ct) + + # And a field assigned only to feature + feature_only_field = MetadataField.objects.create( + name="feature_only_field", + type="str", + organisation=organisation, + project=project, + ) + MetadataModelField.objects.create( + field=feature_only_field, content_type=feature_content_type + ) + + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When - filtering by segment + response = admin_client.get(f"{url}?entity=segment") + + # Then - only the all_entities_field is returned + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert returned_ids == {all_entities_field.id} + + +def test_list_project_metadata_fields__entity_filter_with_include_organisation__returns_both( + admin_client: APIClient, + organisation: Organisation, + project: Project, + feature_content_type: ContentType, + segment_content_type: ContentType, +) -> None: + # Given - an org-level field for feature + org_field = MetadataField.objects.create( + name="org_feature_field", type="str", organisation=organisation + ) + MetadataModelField.objects.create( + field=org_field, content_type=feature_content_type + ) + + # And a project-level field for feature + project_field = MetadataField.objects.create( + name="project_feature_field", + type="str", + organisation=organisation, + project=project, + ) + MetadataModelField.objects.create( + field=project_field, content_type=feature_content_type + ) + + # And a project-level field for segment (should be excluded) + segment_field = MetadataField.objects.create( + name="project_segment_field", + type="str", + organisation=organisation, + project=project, + ) + MetadataModelField.objects.create( + field=segment_field, content_type=segment_content_type + ) + + url = reverse("api-v1:projects:project-metadata-fields-list", args=[project.id]) + + # When + response = admin_client.get(f"{url}?include_organisation=true&entity=feature") + + # Then + assert response.status_code == status.HTTP_200_OK + returned_ids = {r["id"] for r in response.json()["results"]} + assert returned_ids == {org_field.id, project_field.id} From 3cc20aa19a3f656179c2dad294eb6a3fcd38836a Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 15:49:04 +0100 Subject: [PATCH 15/21] feat: fixed-use-effect-on-entity-id-switch --- frontend/web/components/metadata/AddMetadataToEntity.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/metadata/AddMetadataToEntity.tsx b/frontend/web/components/metadata/AddMetadataToEntity.tsx index 47a8cc63ff07..e477050ad12f 100644 --- a/frontend/web/components/metadata/AddMetadataToEntity.tsx +++ b/frontend/web/components/metadata/AddMetadataToEntity.tsx @@ -75,10 +75,10 @@ const AddMetadataToEntity: FC = ({ const { hasUnfilledRequired } = useGlobalMetadataValidation(metadataFields) useEffect(() => { - if (initialFields.length > 0 && metadataFields.length === 0) { + if (initialFields.length > 0) { setMetadataFields(initialFields) } - }, [initialFields, metadataFields.length]) + }, [initialFields]) useEffect(() => { setHasMetadataRequired?.(hasUnfilledRequired) From ce1522aa76da705d640170ab9ee8a61b83c95fa5 Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 16:26:02 +0100 Subject: [PATCH 16/21] feat: fixed-edit-delete-project-permissions-for-custom-fields --- api/metadata/permissions.py | 6 +++- api/metadata/serializers.py | 1 + api/metadata/views.py | 7 ++-- api/tests/unit/metadata/test_views.py | 52 +++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/api/metadata/permissions.py b/api/metadata/permissions.py index 194b567a1900..65cd54a57881 100644 --- a/api/metadata/permissions.py +++ b/api/metadata/permissions.py @@ -42,7 +42,11 @@ def has_object_permission(self, request, view, obj): # type: ignore[no-untyped- "destroy", "partial_update", ): - return request.user.is_organisation_admin(obj.organisation) + if request.user.is_organisation_admin(obj.organisation): + return True + + if obj.project is not None: + return request.user.is_project_admin(obj.project) return False diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 6a22ade1542e..74873d74db4c 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -107,6 +107,7 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: project_id=project_id, ) if self.instance is not None: + assert isinstance(self.instance, MetadataField) qs = qs.exclude(pk=self.instance.pk) if qs.exists(): raise serializers.ValidationError( diff --git a/api/metadata/views.py b/api/metadata/views.py index 91e232a58e69..62ee33ea9ca3 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -9,7 +9,8 @@ from rest_framework.response import Response from app.pagination import CustomPagination -from projects.permissions import VIEW_PROJECT, NestedProjectPermissions +from common.projects.permissions import VIEW_PROJECT +from projects.permissions import NestedProjectPermissions from .models import ( SUPPORTED_REQUIREMENTS_MAPPING, @@ -134,7 +135,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] return MetadataField.objects.none() project = get_object_or_404( - self.request.user.get_permitted_projects(VIEW_PROJECT), + self.request.user.get_permitted_projects(VIEW_PROJECT), # type: ignore[union-attr] pk=self.kwargs["project_pk"], ) @@ -156,7 +157,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] entity = serializer.validated_data.get("entity") if entity: - content_type = ContentType.objects.get(model=entity) + content_type = get_object_or_404(ContentType, model=entity) queryset = queryset.filter( metadatamodelfield__content_type=content_type, ) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 829081c26c42..615c8b37fece 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -906,3 +906,55 @@ def test_list_project_metadata_fields__entity_filter_with_include_organisation__ assert response.status_code == status.HTTP_200_OK returned_ids = {r["id"] for r in response.json()["results"]} assert returned_ids == {org_field.id, project_field.id} + + +@pytest.mark.parametrize( + "field_project_attr, action, expected_status", + [ + ("project", "put", status.HTTP_200_OK), + ("project", "delete", status.HTTP_204_NO_CONTENT), + (None, "put", status.HTTP_403_FORBIDDEN), + (None, "delete", status.HTTP_403_FORBIDDEN), + ("project_b", "put", status.HTTP_403_FORBIDDEN), + ("project_b", "delete", status.HTTP_403_FORBIDDEN), + ], +) +def test_modify_metadata_field__project_admin__permission_check( + staff_user: FFAdminUser, + staff_client: APIClient, + organisation: Organisation, + project: Project, + project_b: Project, + field_project_attr: str | None, + action: str, + expected_status: int, + request: pytest.FixtureRequest, +) -> None: + # Given + from projects.models import UserProjectPermission + + UserProjectPermission.objects.create(user=staff_user, project=project, admin=True) + field_project = ( + request.getfixturevalue(field_project_attr) if field_project_attr else None + ) + field = MetadataField.objects.create( + name="the_field", type="str", organisation=organisation, project=field_project + ) + url = reverse("api-v1:metadata:metadata-fields-detail", args=[field.id]) + + # When + if action == "put": + data = { + "name": "the_field_updated", + "type": "str", + "organisation": organisation.id, + **({"project": field_project.id} if field_project else {}), + } + response = staff_client.put( + url, data=json.dumps(data), content_type="application/json" + ) + else: + response = staff_client.delete(url) + + # Then + assert response.status_code == expected_status From 733ece1cee8743554f562af1ee6ef01a8811d91d Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 16:43:03 +0100 Subject: [PATCH 17/21] feat: refactored-validated-required-metadata --- api/metadata/serializers.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 74873d74db4c..7d30b144c303 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -91,13 +91,12 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: project_id = data.get("project_id") organisation = data.get("organisation") - if project_id is not None: - if not Project.objects.filter( - id=project_id, organisation=organisation - ).exists(): - raise serializers.ValidationError( - {"project": "Project must belong to the specified organisation."} - ) + if project_id is not None and not Project.objects.filter( + id=project_id, organisation=organisation + ).exists(): + raise serializers.ValidationError( + {"project": "Project must belong to the specified organisation."} + ) # Replicate uniqueness checks that DRF can't auto-generate # from conditional UniqueConstraints. @@ -194,31 +193,28 @@ def _validate_required_metadata( model_field__field__organisation=organisation, model_field__field__project__isnull=True, ) + # Requirement scoping: org-level + this project's requirements + req_scope = Q(content_type=org_ct, object_id=organisation.id) + + overridden_names: set[str] = set() if project is not None: field_scope |= Q( model_field__content_type=content_type, model_field__field__organisation=organisation, model_field__field__project=project, ) - - # Requirement scoping: org-level + this project's requirements - req_scope = Q(content_type=org_ct, object_id=organisation.id) - if project is not None: project_ct = ContentType.objects.get_for_model(Project) req_scope |= Q(content_type=project_ct, object_id=project.id) - - requirements = MetadataModelFieldRequirement.objects.filter( - field_scope & req_scope, - ).select_related("model_field__field") - - overridden_names: set[str] = set() - if project is not None: overridden_names = set( MetadataField.objects.filter( organisation=organisation, project=project ).values_list("name", flat=True) ) + requirements = MetadataModelFieldRequirement.objects.filter( + field_scope & req_scope, + ).select_related("model_field__field") + metadata_fields = {field["model_field"] for field in metadata} for requirement in requirements: field = requirement.model_field.field From 01610476acb1d18153fd2ea82647b1cacf4d25ba Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:47:18 +0000 Subject: [PATCH 18/21] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/metadata/serializers.py | 9 ++++++--- api/metadata/views.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 7d30b144c303..5e53b957be88 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -91,9 +91,12 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: project_id = data.get("project_id") organisation = data.get("organisation") - if project_id is not None and not Project.objects.filter( - id=project_id, organisation=organisation - ).exists(): + if ( + project_id is not None + and not Project.objects.filter( + id=project_id, organisation=organisation + ).exists() + ): raise serializers.ValidationError( {"project": "Project must belong to the specified organisation."} ) diff --git a/api/metadata/views.py b/api/metadata/views.py index 62ee33ea9ca3..b70000b9428f 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -1,5 +1,6 @@ from itertools import chain +from common.projects.permissions import VIEW_PROJECT from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator @@ -9,7 +10,6 @@ from rest_framework.response import Response from app.pagination import CustomPagination -from common.projects.permissions import VIEW_PROJECT from projects.permissions import NestedProjectPermissions from .models import ( From 986df6a4ac396bf6ca88e3a7ad8d26261e3b4652 Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 16:49:41 +0100 Subject: [PATCH 19/21] feat: removed-unused-variable --- api/tests/unit/metadata/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/metadata/test_views.py b/api/tests/unit/metadata/test_views.py index 615c8b37fece..172cf8953949 100644 --- a/api/tests/unit/metadata/test_views.py +++ b/api/tests/unit/metadata/test_views.py @@ -563,7 +563,7 @@ def test_list_metadata_fields__response_includes_nested_model_fields( model_field = MetadataModelField.objects.create( field=field, content_type=environment_content_type ) - requirement = MetadataModelFieldRequirement.objects.create( + MetadataModelFieldRequirement.objects.create( content_type=project_content_type, object_id=project.id, model_field=model_field, From 40c5620177f94d66482330a804cb563122a9a50f Mon Sep 17 00:00:00 2001 From: wood Date: Thu, 19 Feb 2026 17:25:17 +0100 Subject: [PATCH 20/21] feat: added-pagination-to-org-metadata-fields --- api/metadata/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/metadata/views.py b/api/metadata/views.py index b70000b9428f..afa32052ddea 100644 --- a/api/metadata/views.py +++ b/api/metadata/views.py @@ -39,6 +39,7 @@ class MetadataFieldViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] permission_classes = [MetadataFieldPermissions] serializer_class = MetadataFieldSerializer + pagination_class = CustomPagination def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): From 59b9278f875aca55a3dc376ed9b98c36d3c0ec1c Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 24 Feb 2026 10:16:34 +0100 Subject: [PATCH 21/21] feat: reviewed-misleading-comment --- api/metadata/serializers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/metadata/serializers.py b/api/metadata/serializers.py index 5e53b957be88..4f72e3e1e9a3 100644 --- a/api/metadata/serializers.py +++ b/api/metadata/serializers.py @@ -82,8 +82,9 @@ class Meta: "project", "model_fields", ) - # Disable auto-generated unique validators — conditional - # UniqueConstraints are enforced at the database level. + # Disable auto-generated unique validators — DRF can't generate + # them for conditional UniqueConstraints. Uniqueness is validated + # manually in validate() below. validators: list[object] = [] def validate(self, data: dict[str, Any]) -> dict[str, Any]: