diff --git a/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx b/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx index eea32a6706..4711e08a84 100644 --- a/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx +++ b/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx @@ -361,18 +361,25 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ ...getTreeItemsMetadata(), { path: 'Event definitions', - category: 'Definitions', + category: 'Schema', iconType: 'event_definition', href: urls.eventDefinitions(), sceneKey: 'EventDefinition', }, { path: 'Property definitions', - category: 'Definitions', + category: 'Schema', iconType: 'property_definition', href: urls.propertyDefinitions(), sceneKey: 'PropertyDefinition', }, + { + path: 'Property groups', + category: 'Schema', + iconType: 'event_definition', + href: urls.schemaManagement(), + flag: FEATURE_FLAGS.SCHEMA_MANAGEMENT, + }, { path: 'Annotations', category: 'Metadata', diff --git a/frontend/src/layout/scenes/components/SceneSection.tsx b/frontend/src/layout/scenes/components/SceneSection.tsx index 4da58586fe..f8b231426d 100644 --- a/frontend/src/layout/scenes/components/SceneSection.tsx +++ b/frontend/src/layout/scenes/components/SceneSection.tsx @@ -48,7 +48,7 @@ export function SceneSection({ {description &&

{description}

} - {actions &&
{actions}
} + {actions &&
{actions}
} {children} @@ -77,7 +77,7 @@ export function SceneSection({ {description &&

{description}

} - {actions &&
{actions}
} + {actions &&
{actions}
} )} {children} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 61efa9f774..a268210929 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,6 +8,8 @@ import { dayjs } from 'lib/dayjs' import { apiStatusLogic } from 'lib/logic/apiStatusLogic' import { humanFriendlyDuration, objectClean, toParams } from 'lib/utils' import { CohortCalculationHistoryResponse } from 'scenes/cohorts/cohortCalculationHistorySceneLogic' +import { EventSchema } from 'scenes/data-management/events/eventDefinitionSchemaLogic' +import { SchemaPropertyGroup } from 'scenes/data-management/schema/schemaManagementLogic' import { MaxBillingContext } from 'scenes/max/maxBillingContextLogic' import { NotebookListItemType, NotebookNodeResource, NotebookType } from 'scenes/notebooks/types' import { RecordingComment } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' @@ -653,6 +655,22 @@ export class ApiRequest { return this.projectsDetail(teamId).addPathComponent('sessions').addPathComponent('property_definitions') } + public schemaPropertyGroups(projectId?: ProjectType['id']): ApiRequest { + return this.projectsDetail(projectId).addPathComponent('schema_property_groups') + } + + public schemaPropertyGroupsDetail(id: string, projectId?: ProjectType['id']): ApiRequest { + return this.schemaPropertyGroups(projectId).addPathComponent(id) + } + + public eventSchemas(projectId?: ProjectType['id']): ApiRequest { + return this.projectsDetail(projectId).addPathComponent('event_schemas') + } + + public eventSchemasDetail(id: string, projectId?: ProjectType['id']): ApiRequest { + return this.eventSchemas(projectId).addPathComponent(id) + } + public dataManagementActivity(teamId?: TeamType['id']): ApiRequest { return this.projectsDetail(teamId).addPathComponent('data_management').addPathComponent('activity') } @@ -2256,6 +2274,43 @@ const api = { }, }, + schemaPropertyGroups: { + async list(projectId?: ProjectType['id']): Promise<{ results: SchemaPropertyGroup[] }> { + return new ApiRequest().schemaPropertyGroups(projectId).get() + }, + async get(id: string, projectId?: ProjectType['id']): Promise { + return new ApiRequest().schemaPropertyGroupsDetail(id, projectId).get() + }, + async create(data: Partial, projectId?: ProjectType['id']): Promise { + return new ApiRequest().schemaPropertyGroups(projectId).create({ data }) + }, + async update( + id: string, + data: Partial, + projectId?: ProjectType['id'] + ): Promise { + return new ApiRequest().schemaPropertyGroupsDetail(id, projectId).update({ data }) + }, + async delete(id: string, projectId?: ProjectType['id']): Promise { + return new ApiRequest().schemaPropertyGroupsDetail(id, projectId).delete() + }, + }, + + eventSchemas: { + async list(eventDefinitionId: string, projectId?: ProjectType['id']): Promise<{ results: EventSchema[] }> { + return new ApiRequest() + .eventSchemas(projectId) + .withQueryString(toParams({ event_definition: eventDefinitionId })) + .get() + }, + async create(data: Partial, projectId?: ProjectType['id']): Promise { + return new ApiRequest().eventSchemas(projectId).create({ data }) + }, + async delete(id: string, projectId?: ProjectType['id']): Promise { + return new ApiRequest().eventSchemasDetail(id, projectId).delete() + }, + }, + cohorts: { async get(cohortId: CohortType['id']): Promise { return await new ApiRequest().cohortsDetail(cohortId).get() diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 33f0413702..a7650a410a 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -226,6 +226,7 @@ export const FEATURE_FLAGS = { EXPERIMENT_INTERVAL_TIMESERIES: 'experiments-interval-timeseries', // owner: @jurajmajerik #team-experiments EXPERIMENTAL_DASHBOARD_ITEM_RENDERING: 'experimental-dashboard-item-rendering', // owner: @thmsobrmlr #team-product-analytics PATHS_V2: 'paths-v2', // owner: @thmsobrmlr #team-product-analytics + SCHEMA_MANAGEMENT: 'schema-management', // owner: @aspicer SDK_DOCTOR_BETA: 'sdk-doctor-beta', // owner: @slshults ONBOARDING_DATA_WAREHOUSE_FOR_PRODUCT_ANALYTICS: 'onboarding-data-warehouse-for-product-analytics', // owner: @joshsny DELAYED_LOADING_ANIMATION: 'delayed-loading-animation', // owner: @raquelmsmith diff --git a/frontend/src/scenes/data-management/DataManagementScene.tsx b/frontend/src/scenes/data-management/DataManagementScene.tsx index 343757e054..76226570aa 100644 --- a/frontend/src/scenes/data-management/DataManagementScene.tsx +++ b/frontend/src/scenes/data-management/DataManagementScene.tsx @@ -32,11 +32,13 @@ import { EventDefinitionsTable } from './events/EventDefinitionsTable' import { IngestionWarningsView } from './ingestion-warnings/IngestionWarningsView' import { DataWarehouseManagedViewsetsScene } from './managed-viewsets/DataWarehouseManagedViewsetsScene' import { PropertyDefinitionsTable } from './properties/PropertyDefinitionsTable' +import { SchemaManagement } from './schema/SchemaManagement' export enum DataManagementTab { Actions = 'actions', EventDefinitions = 'events', PropertyDefinitions = 'properties', + SchemaManagement = 'schema', Annotations = 'annotations', Comments = 'comments', History = 'history', @@ -100,6 +102,12 @@ const tabs: Record = { content: , tooltipDocLink: 'https://posthog.com/docs/new-to-posthog/understand-posthog#properties', }, + [DataManagementTab.SchemaManagement]: { + url: urls.schemaManagement(), + label: 'Property Groups', + content: , + flag: FEATURE_FLAGS.SCHEMA_MANAGEMENT, + }, [DataManagementTab.Annotations]: { url: urls.annotations(), content: , diff --git a/frontend/src/scenes/data-management/definition/DefinitionView.tsx b/frontend/src/scenes/data-management/definition/DefinitionView.tsx index 5905d8d3a5..556ec904f0 100644 --- a/frontend/src/scenes/data-management/definition/DefinitionView.tsx +++ b/frontend/src/scenes/data-management/definition/DefinitionView.tsx @@ -6,12 +6,14 @@ import { IconBadge, IconEye, IconHide, IconInfo } from '@posthog/icons' import { LemonTag, LemonTagType, Spinner, Tooltip } from '@posthog/lemon-ui' import { EditableField } from 'lib/components/EditableField/EditableField' +import { FlaggedFeature } from 'lib/components/FlaggedFeature' import { NotFound } from 'lib/components/NotFound' import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags' import { TZLabel } from 'lib/components/TZLabel' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator' +import { FEATURE_FLAGS } from 'lib/constants' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' @@ -19,6 +21,7 @@ import { IconPlayCircle } from 'lib/lemon-ui/icons' import { DefinitionLogicProps, definitionLogic } from 'scenes/data-management/definition/definitionLogic' import { EventDefinitionInsights } from 'scenes/data-management/events/EventDefinitionInsights' import { EventDefinitionProperties } from 'scenes/data-management/events/EventDefinitionProperties' +import { EventDefinitionSchema } from 'scenes/data-management/events/EventDefinitionSchema' import { LinkedHogFunctions } from 'scenes/hog-functions/list/LinkedHogFunctions' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' @@ -353,6 +356,10 @@ export function DefinitionView(props: DefinitionLogicProps): JSX.Element { {isEvent && definition.id !== 'new' && ( <> + + + + diff --git a/frontend/src/scenes/data-management/events/EventDefinitionSchema.tsx b/frontend/src/scenes/data-management/events/EventDefinitionSchema.tsx new file mode 100644 index 0000000000..ba1afc2eb6 --- /dev/null +++ b/frontend/src/scenes/data-management/events/EventDefinitionSchema.tsx @@ -0,0 +1,135 @@ +import { useActions, useValues } from 'kea' +import { useMemo, useState } from 'react' + +import { IconPencil, IconPlus, IconTrash } from '@posthog/icons' +import { LemonButton, LemonTag } from '@posthog/lemon-ui' + +import { SceneSection } from '~/layout/scenes/components/SceneSection' +import { EventDefinition } from '~/types' + +import { PropertyGroupModal } from '../schema/PropertyGroupModal' +import { SelectPropertyGroupModal } from '../schema/SelectPropertyGroupModal' +import { SchemaPropertyGroupProperty, schemaManagementLogic } from '../schema/schemaManagementLogic' +import { EventSchema, eventDefinitionSchemaLogic } from './eventDefinitionSchemaLogic' + +function PropertyRow({ property }: { property: SchemaPropertyGroupProperty }): JSX.Element { + return ( +
+
+ {property.name} +
+
+ {property.property_type} +
+
+ {property.is_required ? ( + Required + ) : ( + Optional + )} +
+
{property.description || '—'}
+
+ ) +} + +export function EventDefinitionSchema({ definition }: { definition: EventDefinition }): JSX.Element { + const logic = eventDefinitionSchemaLogic({ eventDefinitionId: definition.id }) + const { eventSchemas, eventSchemasLoading } = useValues(logic) + const { addPropertyGroup, removePropertyGroup, loadAllPropertyGroups } = useActions(logic) + const [isModalOpen, setIsModalOpen] = useState(false) + + const schemaLogic = schemaManagementLogic({ key: `event-${definition.id}` }) + const { setPropertyGroupModalOpen, setEditingPropertyGroup } = useActions(schemaLogic) + + const selectedPropertyGroupIds = useMemo>( + () => new Set(eventSchemas.map((schema: EventSchema) => schema.property_group.id)), + [eventSchemas] + ) + + return ( + } + onClick={() => setIsModalOpen(true)} + disabled={eventSchemasLoading} + > + Add Property Group + + } + > +
+ setIsModalOpen(false)} + onSelect={(propertyGroupId) => addPropertyGroup(propertyGroupId)} + selectedPropertyGroupIds={selectedPropertyGroupIds} + onPropertyGroupCreated={() => { + loadAllPropertyGroups() + }} + /> + + {eventSchemas.length > 0 ? ( +
+ {eventSchemas.map((schema: EventSchema) => ( +
+
+
+ {schema.property_group.name} + + {schema.property_group.properties?.length || 0}{' '} + {schema.property_group.properties?.length === 1 ? 'property' : 'properties'} + +
+
+ } + size="small" + onClick={() => { + setEditingPropertyGroup(schema.property_group) + setPropertyGroupModalOpen(true) + }} + tooltip="Edit this property group" + /> + } + size="small" + status="danger" + onClick={() => removePropertyGroup(schema.id)} + tooltip="Remove this property group from the event schema" + /> +
+
+ {schema.property_group.properties && schema.property_group.properties.length > 0 && ( + <> +
+
Property
+
Type
+
Required
+
Description
+
+ {schema.property_group.properties.map( + (property: SchemaPropertyGroupProperty) => ( + + ) + )} + + )} +
+ ))} +
+ ) : ( +
+ No property groups added yet. Add a property group above to define the schema for this event. +
+ )} +
+ + +
+ ) +} diff --git a/frontend/src/scenes/data-management/events/eventDefinitionSchemaLogic.test.ts b/frontend/src/scenes/data-management/events/eventDefinitionSchemaLogic.test.ts new file mode 100644 index 0000000000..9732a538a6 --- /dev/null +++ b/frontend/src/scenes/data-management/events/eventDefinitionSchemaLogic.test.ts @@ -0,0 +1,113 @@ +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { schemaManagementLogic } from '../schema/schemaManagementLogic' +import { eventDefinitionSchemaLogic } from './eventDefinitionSchemaLogic' + +describe('eventDefinitionSchemaLogic', () => { + let logic: ReturnType + let schemaLogic: ReturnType + + beforeEach(() => { + initKeaTests() + + useMocks({ + get: { + '/api/projects/:teamId/event_schemas': { + results: [ + { + id: 'schema-1', + event_definition: 'event-def-1', + property_group_id: 'group-1', + property_group: { + id: 'group-1', + name: 'Test Group', + properties: [{ id: 'prop-1', name: 'prop1', property_type: 'String' }], + }, + }, + ], + }, + '/api/projects/:teamId/schema_property_groups/': { + results: [ + { + id: 'group-1', + name: 'Test Group', + properties: [{ id: 'prop-1', name: 'prop1', property_type: 'String' }], + }, + ], + }, + }, + patch: { + '/api/projects/:teamId/schema_property_groups/:id/': () => [ + 200, + { + id: 'group-1', + name: 'Updated Group', + properties: [{ id: 'prop-1', name: 'updatedProp', property_type: 'Numeric' }], + }, + ], + }, + post: { + '/api/projects/:teamId/schema_property_groups/': () => [ + 200, + { + id: 'group-2', + name: 'New Group', + properties: [], + }, + ], + }, + }) + + // Build the logic instances with the same key + schemaLogic = schemaManagementLogic({ key: 'event-event-def-1' }) + logic = eventDefinitionSchemaLogic({ eventDefinitionId: 'event-def-1' }) + }) + + it('should reload event schemas when property group is updated', () => { + schemaLogic.mount() + logic.mount() + + const loadEventSchemasSpy = jest.spyOn(logic.actions, 'loadEventSchemas') + + const mockPropertyGroups = [ + { + id: 'group-1', + name: 'Updated Group', + properties: [ + { + id: 'prop-1', + name: 'updatedProp', + property_type: 'Numeric', + is_required: false, + description: '', + order: 0, + }, + ], + }, + ] + + schemaLogic.actions.updatePropertyGroupSuccess(mockPropertyGroups) + + expect(loadEventSchemasSpy).toHaveBeenCalled() + }) + + it('should reload event schemas when a new property group is created', () => { + schemaLogic.mount() + logic.mount() + + const loadEventSchemasSpy = jest.spyOn(logic.actions, 'loadEventSchemas') + + const mockNewPropertyGroup = [ + { + id: 'group-2', + name: 'New Group', + properties: [], + }, + ] + + schemaLogic.actions.createPropertyGroupSuccess(mockNewPropertyGroup) + + expect(loadEventSchemasSpy).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/scenes/data-management/events/eventDefinitionSchemaLogic.ts b/frontend/src/scenes/data-management/events/eventDefinitionSchemaLogic.ts new file mode 100644 index 0000000000..9efb22a9f8 --- /dev/null +++ b/frontend/src/scenes/data-management/events/eventDefinitionSchemaLogic.ts @@ -0,0 +1,139 @@ +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' + +import api from 'lib/api' +import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' + +import { type SchemaPropertyGroup, schemaManagementLogic } from '../schema/schemaManagementLogic' +import type { eventDefinitionSchemaLogicType } from './eventDefinitionSchemaLogicType' + +export type { SchemaPropertyGroup } + +function getErrorMessage(error: any, defaultMessage: string): string { + if (error.detail) { + const detail = error.detail + + // Handle "property group is already added to this event schema" + if ( + typeof detail === 'string' && + (detail.includes('already added to this event schema') || detail.includes('already exists')) + ) { + // Extract property group name if available + const nameMatch = detail.match(/Property group '([^']+)'/) + if (nameMatch) { + return `Property group "${nameMatch[1]}" is already added to this event` + } + return detail + } + + // Handle team mismatch errors + if (typeof detail === 'string' && detail.includes('must belong to the same team')) { + return 'Property group must belong to the same team as the event' + } + + // Return the detail if it's a user-friendly string + if (typeof detail === 'string' && !detail.includes('IntegrityError') && !detail.includes('Key (')) { + return detail + } + } + + if (error.message && !error.message.includes('IntegrityError')) { + return error.message + } + + return defaultMessage +} + +export interface EventSchema { + id: string + event_definition: string + property_group_id: string + property_group: SchemaPropertyGroup + created_at: string + updated_at: string +} + +export interface EventDefinitionSchemaLogicProps { + eventDefinitionId: string +} + +export const eventDefinitionSchemaLogic = kea([ + path(['scenes', 'data-management', 'events', 'eventDefinitionSchemaLogic']), + props({} as EventDefinitionSchemaLogicProps), + key((props) => props.eventDefinitionId), + connect((props: EventDefinitionSchemaLogicProps) => ({ + actions: [ + schemaManagementLogic({ key: `event-${props.eventDefinitionId}` }), + ['updatePropertyGroupSuccess', 'createPropertyGroupSuccess'], + ], + })), + actions({ + addPropertyGroup: (propertyGroupId: string) => ({ propertyGroupId }), + removePropertyGroup: (eventSchemaId: string) => ({ eventSchemaId }), + }), + loaders(({ props }) => ({ + eventSchemas: { + __default: [] as EventSchema[], + loadEventSchemas: async () => { + const response = await api.eventSchemas.list(props.eventDefinitionId) + return response.results || [] + }, + }, + allPropertyGroups: { + __default: [] as SchemaPropertyGroup[], + loadAllPropertyGroups: async () => { + const response = await api.schemaPropertyGroups.list() + return response.results || [] + }, + }, + })), + reducers({ + eventSchemas: { + removePropertyGroup: (state, { eventSchemaId }) => + state.filter((schema: EventSchema) => schema.id !== eventSchemaId), + }, + }), + selectors({ + availablePropertyGroups: [ + (s) => [s.allPropertyGroups, s.eventSchemas], + (allPropertyGroups: SchemaPropertyGroup[], eventSchemas: EventSchema[]): SchemaPropertyGroup[] => { + const usedGroupIds = new Set(eventSchemas.map((schema: EventSchema) => schema.property_group_id)) + return allPropertyGroups.filter((group: SchemaPropertyGroup) => !usedGroupIds.has(group.id)) + }, + ], + }), + listeners(({ actions, props }) => ({ + addPropertyGroup: async ({ propertyGroupId }) => { + try { + await api.eventSchemas.create({ + event_definition: props.eventDefinitionId, + property_group_id: propertyGroupId, + }) + await actions.loadEventSchemas() + lemonToast.success('Property group added to event schema') + } catch (error: any) { + const errorMessage = getErrorMessage(error, 'Failed to add property group') + lemonToast.error(errorMessage) + } + }, + removePropertyGroup: async ({ eventSchemaId }) => { + try { + await api.eventSchemas.delete(eventSchemaId) + lemonToast.success('Property group removed from event schema') + } catch (error: any) { + const errorMessage = getErrorMessage(error, 'Failed to remove property group') + lemonToast.error(errorMessage) + } + }, + updatePropertyGroupSuccess: async () => { + await actions.loadEventSchemas() + }, + createPropertyGroupSuccess: async () => { + await actions.loadEventSchemas() + }, + })), + afterMount(({ actions }) => { + actions.loadEventSchemas() + actions.loadAllPropertyGroups() + }), +]) diff --git a/frontend/src/scenes/data-management/schema/PropertyGroupModal.tsx b/frontend/src/scenes/data-management/schema/PropertyGroupModal.tsx new file mode 100644 index 0000000000..0866c9e09f --- /dev/null +++ b/frontend/src/scenes/data-management/schema/PropertyGroupModal.tsx @@ -0,0 +1,190 @@ +import { useActions, useValues } from 'kea' +import { Form } from 'kea-forms' + +import { IconPlus, IconTrash } from '@posthog/icons' +import { LemonButton, LemonCheckbox, LemonInput, LemonModal, LemonSelect, LemonTextArea } from '@posthog/lemon-ui' + +import { LemonField } from 'lib/lemon-ui/LemonField' +import { LemonTable, LemonTableColumns } from 'lib/lemon-ui/LemonTable' + +import { PropertyType, SchemaPropertyGroupProperty, schemaManagementLogic } from './schemaManagementLogic' + +const PROPERTY_TYPE_OPTIONS: { value: PropertyType; label: string }[] = [ + { value: 'String', label: 'String' }, + { value: 'Numeric', label: 'Numeric' }, + { value: 'Boolean', label: 'Boolean' }, + { value: 'DateTime', label: 'DateTime' }, + { value: 'Duration', label: 'Duration' }, +] + +function isValidPropertyName(name: string): boolean { + if (!name || !name.trim()) { + return false + } + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name.trim()) +} + +interface PropertyGroupModalProps { + logicKey?: string + onAfterSave?: () => void | Promise +} + +export function PropertyGroupModal({ logicKey, onAfterSave }: PropertyGroupModalProps = {}): JSX.Element { + const logic = schemaManagementLogic({ key: logicKey || 'default' }) + const { + propertyGroupModalOpen, + editingPropertyGroup, + propertyGroupForm, + isPropertyGroupFormSubmitting, + propertyGroupFormValidationError, + } = useValues(logic) + const { + setPropertyGroupModalOpen, + addPropertyToForm, + updatePropertyInForm, + removePropertyFromForm, + submitPropertyGroupForm, + } = useActions(logic) + + const handleClose = (): void => { + setPropertyGroupModalOpen(false) + } + + const handleAfterSubmit = async (): Promise => { + await onAfterSave?.() + } + + const columns: LemonTableColumns = [ + { + title: 'Name', + key: 'name', + render: (_, property, index) => ( + updatePropertyInForm(index, { name: value })} + placeholder="Property name" + status={!isValidPropertyName(property.name) ? 'danger' : undefined} + fullWidth + /> + ), + }, + { + title: 'Type', + key: 'property_type', + width: 150, + render: (_, property, index) => ( + updatePropertyInForm(index, { property_type: value as PropertyType })} + options={PROPERTY_TYPE_OPTIONS} + fullWidth + /> + ), + }, + { + title: 'Required', + key: 'is_required', + width: 100, + align: 'center', + render: (_, property, index) => ( +
updatePropertyInForm(index, { is_required: !property.is_required })} + > + updatePropertyInForm(index, { is_required: checked })} + /> +
+ ), + }, + { + title: 'Description', + key: 'description', + render: (_, property, index) => ( + updatePropertyInForm(index, { description: value })} + placeholder="Optional description" + fullWidth + /> + ), + }, + { + key: 'actions', + width: 50, + render: (_, _property, index) => ( + } size="small" onClick={() => removePropertyFromForm(index)} /> + ), + }, + ] + + return ( + +
+ + + + + + + + +
+
+

Properties

+ } size="small" onClick={addPropertyToForm}> + Add property + +
+ + {propertyGroupForm.properties.length > 0 ? ( + <> + + {propertyGroupFormValidationError && ( +
{propertyGroupFormValidationError}
+ )} + + ) : ( +
+ No properties yet. Click "Add property" to get started. +
+ )} +
+ +
+ + Cancel + + { + await submitPropertyGroupForm() + await handleAfterSubmit() + }} + > + {editingPropertyGroup ? 'Update' : 'Create'} + +
+
+
+ ) +} diff --git a/frontend/src/scenes/data-management/schema/SchemaManagement.tsx b/frontend/src/scenes/data-management/schema/SchemaManagement.tsx new file mode 100644 index 0000000000..3d89abae73 --- /dev/null +++ b/frontend/src/scenes/data-management/schema/SchemaManagement.tsx @@ -0,0 +1,225 @@ +import { useActions, useValues } from 'kea' + +import { IconApps, IconPencil, IconPlus, IconTrash } from '@posthog/icons' +import { LemonButton, LemonInput, LemonTag, Link } from '@posthog/lemon-ui' + +import { NotFound } from 'lib/components/NotFound' +import { FEATURE_FLAGS } from 'lib/constants' +import { LemonTable, LemonTableColumns } from 'lib/lemon-ui/LemonTable' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { SceneContent } from '~/layout/scenes/components/SceneContent' +import { SceneDivider } from '~/layout/scenes/components/SceneDivider' +import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection' +import { urls } from '~/scenes/urls' + +import { PropertyGroupModal } from './PropertyGroupModal' +import { + EventDefinitionBasic, + SchemaPropertyGroup, + SchemaPropertyGroupProperty, + schemaManagementLogic, +} from './schemaManagementLogic' + +function EventRow({ event }: { event: EventDefinitionBasic }): JSX.Element { + return ( +
+ + {event.name} + +
+ ) +} + +function PropertyRow({ property }: { property: SchemaPropertyGroupProperty }): JSX.Element { + return ( +
+
+ + {property.name} + +
+
+ {property.property_type} +
+
+ {property.is_required ? ( + Required + ) : ( + Optional + )} +
+
{property.description || '—'}
+
+ ) +} + +export function SchemaManagement(): JSX.Element { + const { featureFlags } = useValues(featureFlagLogic) + const { filteredPropertyGroups, propertyGroupsLoading, searchTerm } = useValues(schemaManagementLogic) + const { setSearchTerm, setPropertyGroupModalOpen, setEditingPropertyGroup, deletePropertyGroup } = + useActions(schemaManagementLogic) + + if (!featureFlags[FEATURE_FLAGS.SCHEMA_MANAGEMENT]) { + return + } + + const columns: LemonTableColumns = [ + { + key: 'expander', + width: 0, + }, + { + title: 'Name', + key: 'name', + dataIndex: 'name', + render: (name) => {name}, + }, + { + title: 'Description', + key: 'description', + dataIndex: 'description', + render: (description) => {description || '—'}, + }, + { + title: 'Properties', + key: 'property_count', + width: 120, + render: (_, propertyGroup) => ( + + {propertyGroup.properties?.length || 0}{' '} + {propertyGroup.properties?.length === 1 ? 'property' : 'properties'} + + ), + }, + { + key: 'actions', + width: 100, + render: (_, propertyGroup) => ( +
+ } + size="small" + onClick={() => { + setEditingPropertyGroup(propertyGroup) + setPropertyGroupModalOpen(true) + }} + /> + } + size="small" + status="danger" + onClick={() => { + if ( + confirm( + `Are you sure you want to delete "${propertyGroup.name}"? This action cannot be undone.` + ) + ) { + deletePropertyGroup(propertyGroup.id) + } + }} + /> +
+ ), + }, + ] + + return ( + + , + }} + /> + +
+
+ + } + onClick={() => { + setEditingPropertyGroup(null) + setPropertyGroupModalOpen(true) + }} + > + New Property Group + +
+ + ( +
+ {/* Events Section */} +
+

Events

+
+ {propertyGroup.events && propertyGroup.events.length > 0 ? ( + <> +
+
Event Name
+
+ {propertyGroup.events.map((event) => ( + + ))} + + ) : ( +
+ No events using this property group +
+ )} +
+
+ + {/* Properties Section */} +
+

Properties

+
+ {propertyGroup.properties && propertyGroup.properties.length > 0 ? ( + <> +
+
Property
+
Type
+
Required
+
Description
+
+ {propertyGroup.properties.map((property) => ( + + ))} + + ) : ( +
No properties defined
+ )} +
+
+
+ ), + rowExpandable: (propertyGroup) => + (propertyGroup.events && propertyGroup.events.length > 0) || + (propertyGroup.properties && propertyGroup.properties.length > 0), + }} + emptyState="No property groups yet. Create one to get started!" + /> +
+ + +
+ ) +} diff --git a/frontend/src/scenes/data-management/schema/SelectPropertyGroupModal.tsx b/frontend/src/scenes/data-management/schema/SelectPropertyGroupModal.tsx new file mode 100644 index 0000000000..9cbabedbf5 --- /dev/null +++ b/frontend/src/scenes/data-management/schema/SelectPropertyGroupModal.tsx @@ -0,0 +1,169 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { IconPlusSmall } from '@posthog/icons' +import { LemonButton, LemonInput, LemonModal, LemonTag } from '@posthog/lemon-ui' + +import { LemonTable, LemonTableColumns } from 'lib/lemon-ui/LemonTable' + +import { PropertyGroupModal } from './PropertyGroupModal' +import { SchemaPropertyGroup, SchemaPropertyGroupProperty, schemaManagementLogic } from './schemaManagementLogic' + +function PropertyRow({ property }: { property: SchemaPropertyGroupProperty }): JSX.Element { + return ( +
+
+ {property.name} +
+
+ {property.property_type} +
+
+ {property.is_required ? ( + Required + ) : ( + Optional + )} +
+
{property.description || '—'}
+
+ ) +} + +interface SelectPropertyGroupModalProps { + isOpen: boolean + onClose: () => void + onSelect: (propertyGroupId: string) => void + selectedPropertyGroupIds?: Set + onPropertyGroupCreated?: () => void +} + +export function SelectPropertyGroupModal({ + isOpen, + onClose, + onSelect, + selectedPropertyGroupIds = new Set(), + onPropertyGroupCreated, +}: SelectPropertyGroupModalProps): JSX.Element { + const [searchTerm, setSearchTerm] = useState('') + const logic = schemaManagementLogic({ key: 'select-property-group-modal' }) + const { propertyGroups } = useValues(logic) + const { setPropertyGroupModalOpen, loadPropertyGroups } = useActions(logic) + + const filteredPropertyGroups = propertyGroups.filter( + (group) => + !selectedPropertyGroupIds.has(group.id) && + (searchTerm === '' || group.name.toLowerCase().includes(searchTerm.toLowerCase())) + ) + + const handleCreateNewGroup = (): void => { + setPropertyGroupModalOpen(true) + } + + const handleAfterPropertyGroupSave = async (): Promise => { + await loadPropertyGroups() + onPropertyGroupCreated?.() + } + + const columns: LemonTableColumns = [ + { + key: 'expander', + width: 0, + }, + { + title: 'Name', + key: 'name', + dataIndex: 'name', + render: (name) => {name}, + }, + { + title: 'Description', + key: 'description', + dataIndex: 'description', + render: (description) => {description || '—'}, + }, + { + title: 'Properties', + key: 'property_count', + width: 120, + render: (_, propertyGroup) => ( + + {propertyGroup.properties?.length || 0}{' '} + {propertyGroup.properties?.length === 1 ? 'property' : 'properties'} + + ), + }, + { + key: 'actions', + width: 100, + render: (_, propertyGroup) => ( + } + onClick={() => { + onSelect(propertyGroup.id) + onClose() + }} + > + Add + + ), + }, + ] + + return ( + <> + +
+
+ + } onClick={handleCreateNewGroup}> + New Property Group + +
+ + ( +
+ {propertyGroup.properties && propertyGroup.properties.length > 0 ? ( + <> +
+
Property
+
Type
+
Required
+
Description
+
+ {propertyGroup.properties.map((property) => ( + + ))} + + ) : ( +
No properties defined
+ )} +
+ ), + rowExpandable: () => true, + }} + emptyState={ + searchTerm + ? 'No property groups match your search' + : 'No property groups available. Create one in Schema Management first.' + } + /> +
+
+ + + ) +} diff --git a/frontend/src/scenes/data-management/schema/schemaManagementLogic.ts b/frontend/src/scenes/data-management/schema/schemaManagementLogic.ts new file mode 100644 index 0000000000..4e72b31e5a --- /dev/null +++ b/frontend/src/scenes/data-management/schema/schemaManagementLogic.ts @@ -0,0 +1,282 @@ +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { forms } from 'kea-forms' +import { loaders } from 'kea-loaders' + +import api from 'lib/api' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { teamLogic } from 'scenes/teamLogic' + +import type { schemaManagementLogicType } from './schemaManagementLogicType' + +export type PropertyType = 'String' | 'Numeric' | 'Boolean' | 'DateTime' | 'Duration' + +function getErrorMessage(error: any, defaultMessage: string): string { + // Handle field-specific errors from DRF serializer + if (error.name) { + return error.name + } + if (error.properties) { + return error.properties + } + + // Handle detail string errors + if (error.detail) { + const detail = error.detail + + // Handle duplicate property name constraint + if (typeof detail === 'string' && detail.includes('unique_property_group_property_name')) { + const nameMatch = detail.match(/\(property_group_id, name\)=\([^,]+, ([^)]+)\)/) + if (nameMatch) { + return `A property named "${nameMatch[1]}" already exists in this group` + } + return 'A property with this name already exists in this group' + } + + // Handle duplicate property group name constraint + if (typeof detail === 'string' && detail.includes('unique_property_group_name')) { + const nameMatch = detail.match(/\(team_id, name\)=\([^,]+, ([^)]+)\)/) + if (nameMatch) { + return `A property group named "${nameMatch[1]}" already exists` + } + return 'A property group with this name already exists' + } + + // Return the detail if it's a user-friendly string + if (typeof detail === 'string' && !detail.includes('IntegrityError') && !detail.includes('Key (')) { + return detail + } + } + + if (error.message && !error.message.includes('IntegrityError')) { + return error.message + } + + return defaultMessage +} + +export interface SchemaPropertyGroupProperty { + id: string + name: string + property_type: PropertyType + is_required: boolean + description: string +} + +export interface EventDefinitionBasic { + id: string + name: string +} + +export interface SchemaPropertyGroup { + id: string + name: string + description: string + properties: SchemaPropertyGroupProperty[] + events: EventDefinitionBasic[] + created_at: string + updated_at: string +} + +export interface SchemaManagementLogicProps { + key?: string +} + +export interface PropertyGroupFormType { + name: string + description: string + properties: SchemaPropertyGroupProperty[] +} + +export const schemaManagementLogic = kea([ + path(['scenes', 'data-management', 'schema', 'schemaManagementLogic']), + props({} as SchemaManagementLogicProps), + key((props) => props.key || 'default'), + connect({ + values: [teamLogic, ['currentTeamId']], + }), + actions({ + setSearchTerm: (searchTerm: string) => ({ searchTerm }), + setPropertyGroupModalOpen: (open: boolean) => ({ open }), + setEditingPropertyGroup: (propertyGroup: SchemaPropertyGroup | null) => ({ propertyGroup }), + deletePropertyGroup: (id: string) => ({ id }), + addPropertyToForm: true, + updatePropertyInForm: (index: number, updates: Partial) => ({ index, updates }), + removePropertyFromForm: (index: number) => ({ index }), + }), + loaders(({ values }) => ({ + propertyGroups: [ + [] as SchemaPropertyGroup[], + { + loadPropertyGroups: async () => { + const response = await api.get(`api/projects/@current/schema_property_groups/`) + return response.results || response || [] + }, + createPropertyGroup: async (data: Partial) => { + try { + const response = await api.create(`api/projects/@current/schema_property_groups/`, data) + lemonToast.success('Property group created') + return [response, ...values.propertyGroups] + } catch (error: any) { + const errorMessage = getErrorMessage(error, 'Failed to create property group') + lemonToast.error(errorMessage) + throw new Error(errorMessage) + } + }, + updatePropertyGroup: async ({ id, data }: { id: string; data: Partial }) => { + try { + const response = await api.update(`api/projects/@current/schema_property_groups/${id}/`, data) + lemonToast.success('Property group updated') + return values.propertyGroups.map((pg) => (pg.id === id ? response : pg)) + } catch (error: any) { + const errorMessage = getErrorMessage(error, 'Failed to update property group') + lemonToast.error(errorMessage) + throw new Error(errorMessage) + } + }, + }, + ], + })), + forms(({ actions, values }) => ({ + propertyGroupForm: { + options: { showErrorsOnTouch: true }, + defaults: { name: '', description: '', properties: [] } as PropertyGroupFormType, + errors: ({ name }) => ({ + name: !name?.trim() ? 'Property group name is required' : undefined, + }), + submit: async (formValues) => { + // Check for validation errors + const validationError = values.propertyGroupFormValidationError + if (validationError) { + lemonToast.error(validationError) + throw new Error(validationError) + } + + const data = { + name: formValues.name, + description: formValues.description, + properties: formValues.properties.map((p) => ({ ...p, name: p.name.trim() })), + } + + try { + if (values.editingPropertyGroup) { + await actions.updatePropertyGroup({ id: values.editingPropertyGroup.id, data }) + } else { + await actions.createPropertyGroup(data) + } + actions.setPropertyGroupModalOpen(false) + } catch (error) { + // Error is already handled by the loaders + throw error + } + }, + }, + })), + reducers({ + searchTerm: [ + '', + { + setSearchTerm: (_, { searchTerm }) => searchTerm, + }, + ], + propertyGroupModalOpen: [ + false, + { + setPropertyGroupModalOpen: (_, { open }) => open, + }, + ], + editingPropertyGroup: [ + null as SchemaPropertyGroup | null, + { + setEditingPropertyGroup: (_, { propertyGroup }) => propertyGroup, + setPropertyGroupModalOpen: (state, { open }) => (open ? state : null), + }, + ], + propertyGroupForm: [ + { name: '', description: '', properties: [] } as PropertyGroupFormType, + { + addPropertyToForm: (state) => ({ + ...state, + properties: [ + ...state.properties, + { + id: `new-${Date.now()}`, + name: '', + property_type: 'String' as PropertyType, + is_required: false, + description: '', + }, + ], + }), + updatePropertyInForm: (state, { index, updates }) => ({ + ...state, + properties: state.properties.map((prop, i) => (i === index ? { ...prop, ...updates } : prop)), + }), + removePropertyFromForm: (state, { index }) => ({ + ...state, + properties: state.properties.filter((_, i) => i !== index), + }), + setEditingPropertyGroup: (_, { propertyGroup }) => ({ + name: propertyGroup?.name || '', + description: propertyGroup?.description || '', + properties: propertyGroup?.properties || [], + }), + setPropertyGroupModalOpen: (state, { open }) => + open ? state : { name: '', description: '', properties: [] }, + }, + ], + }), + selectors({ + filteredPropertyGroups: [ + (s) => [s.propertyGroups, s.searchTerm], + (propertyGroups, searchTerm): SchemaPropertyGroup[] => { + if (!searchTerm) { + return propertyGroups + } + const lowerSearchTerm = searchTerm.toLowerCase() + return propertyGroups.filter( + (pg) => + pg.name.toLowerCase().includes(lowerSearchTerm) || + pg.description.toLowerCase().includes(lowerSearchTerm) || + pg.properties.some((prop) => prop.name.toLowerCase().includes(lowerSearchTerm)) + ) + }, + ], + propertyGroupFormValidationError: [ + (s) => [s.propertyGroupForm], + (form): string | null => { + if (form.properties.length === 0) { + return null + } + + const emptyProperties = form.properties.filter((prop) => !prop.name || !prop.name.trim()) + if (emptyProperties.length > 0) { + return 'All properties must have a name' + } + + const invalidProperties = form.properties.filter( + (prop) => prop.name && prop.name.trim() && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(prop.name.trim()) + ) + if (invalidProperties.length > 0) { + return 'Property names must start with a letter or underscore and contain only letters, numbers, and underscores' + } + + return null + }, + ], + }), + listeners(({ actions }) => ({ + deletePropertyGroup: async ({ id }) => { + try { + await api.delete(`api/projects/@current/schema_property_groups/${id}/`) + actions.loadPropertyGroups() + lemonToast.success('Property group deleted') + } catch (error: any) { + const errorMessage = getErrorMessage(error, 'Failed to delete property group') + lemonToast.error(errorMessage) + } + }, + })), + afterMount(({ actions }) => { + actions.loadPropertyGroups() + }), +]) diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 1a5140f7f1..b0e641bb11 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -583,6 +583,7 @@ export const routes: Record = { [urls.propertyDefinitions()]: [Scene.DataManagement, 'propertyDefinitions'], [urls.propertyDefinition(':id')]: [Scene.PropertyDefinition, 'propertyDefinition'], [urls.propertyDefinitionEdit(':id')]: [Scene.PropertyDefinitionEdit, 'propertyDefinitionEdit'], + [urls.schemaManagement()]: [Scene.DataManagement, 'schemaManagement'], [urls.dataManagementHistory()]: [Scene.DataManagement, 'dataManagementHistory'], [urls.database()]: [Scene.DataManagement, 'database'], [urls.activity(ActivityTab.ExploreEvents)]: [Scene.ExploreEvents, 'exploreEvents'], diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 0435a27959..aaa93f8432 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -38,6 +38,7 @@ export const urls = { propertyDefinitions: (type?: string): string => combineUrl('/data-management/properties', type ? { type } : {}).url, propertyDefinition: (id: string | number): string => `/data-management/properties/${id}`, propertyDefinitionEdit: (id: string | number): string => `/data-management/properties/${id}/edit`, + schemaManagement: (): string => '/data-management/schema', dataManagementHistory: (): string => '/data-management/history', database: (): string => '/data-management/database', dataWarehouseManagedViewsets: (): string => '/data-management/managed-viewsets', diff --git a/playwright/__snapshots__/pageview-trends-insight.png b/playwright/__snapshots__/pageview-trends-insight.png index 72c1b0afdd..7ade46cb4f 100644 Binary files a/playwright/__snapshots__/pageview-trends-insight.png and b/playwright/__snapshots__/pageview-trends-insight.png differ diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py index df47e53b64..de959e990e 100644 --- a/posthog/api/__init__.py +++ b/posthog/api/__init__.py @@ -59,6 +59,7 @@ from . import ( dead_letter_queue, debug_ch_queries, event_definition, + event_schema, exports, feature_flag, flag_value, @@ -81,6 +82,7 @@ from . import ( proxy_record, query, scheduled_change, + schema_property_group, search, sharing, survey, @@ -361,6 +363,18 @@ projects_router.register( "project_property_definitions", ["project_id"], ) +projects_router.register( + r"schema_property_groups", + schema_property_group.SchemaPropertyGroupViewSet, + "project_schema_property_groups", + ["project_id"], +) +projects_router.register( + r"event_schemas", + event_schema.EventSchemaViewSet, + "project_event_schemas", + ["project_id"], +) projects_router.register(r"uploaded_media", uploaded_media.MediaViewSet, "project_media", ["project_id"]) diff --git a/posthog/api/event_schema.py b/posthog/api/event_schema.py new file mode 100644 index 0000000000..5ea6a22bee --- /dev/null +++ b/posthog/api/event_schema.py @@ -0,0 +1,98 @@ +from rest_framework import mixins, serializers, viewsets + +from posthog.api.routing import TeamAndOrgViewSetMixin +from posthog.api.schema_property_group import SchemaPropertyGroupSerializer +from posthog.models import EventDefinition, EventSchema, SchemaPropertyGroup + + +class EventSchemaSerializer(serializers.ModelSerializer): + property_group = SchemaPropertyGroupSerializer(read_only=True) + property_group_id = serializers.PrimaryKeyRelatedField( + queryset=SchemaPropertyGroup.objects.none(), source="property_group", write_only=True + ) + + def get_fields(self): + fields = super().get_fields() + request = self.context.get("request") + if request and hasattr(request, "user"): + team_id = self.context.get("team_id") + if team_id: + fields["property_group_id"].queryset = SchemaPropertyGroup.objects.filter(team_id=team_id) # type: ignore + fields["event_definition"].queryset = EventDefinition.objects.filter(team_id=team_id) # type: ignore + return fields + + class Meta: + model = EventSchema + fields = ( + "id", + "event_definition", + "property_group", + "property_group_id", + "created_at", + "updated_at", + ) + read_only_fields = ("id", "property_group", "created_at", "updated_at") + + def validate(self, attrs): + event_definition = attrs.get("event_definition") + property_group = attrs.get("property_group") + + if event_definition and property_group: + if EventSchema.objects.filter(event_definition=event_definition, property_group=property_group).exists(): + raise serializers.ValidationError( + f"Property group '{property_group.name}' is already added to this event schema" + ) + + return attrs + + def create(self, validated_data): + instance = EventSchema.objects.create(**validated_data) + return EventSchema.objects.prefetch_related("property_group__properties").get(pk=instance.pk) + + +class EventSchemaViewSet( + TeamAndOrgViewSetMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + scope_object = "INTERNAL" + serializer_class = EventSchemaSerializer + queryset = EventSchema.objects.all() + lookup_field = "id" + + def _filter_queryset_by_parents_lookups(self, queryset): + """Override to handle EventSchema which doesn't have a direct team field""" + parents_query_dict = self.parents_query_dict.copy() + + # Rewrite team/project lookups to use event_definition__team + if "team_id" in parents_query_dict: + parents_query_dict["event_definition__team_id"] = parents_query_dict.pop("team_id") + if "project_id" in parents_query_dict: + parents_query_dict["event_definition__team__project_id"] = parents_query_dict.pop("project_id") + + if parents_query_dict: + try: + return queryset.filter(**parents_query_dict) + except ValueError: + from rest_framework.exceptions import NotFound + + raise NotFound() + else: + return queryset + + def safely_get_queryset(self, queryset): + event_definition_id = self.request.query_params.get("event_definition") + if event_definition_id: + return ( + queryset.filter(event_definition_id=event_definition_id) + .select_related("property_group") + .prefetch_related("property_group__properties") + .order_by("-created_at") + ) + return ( + queryset.select_related("property_group") + .prefetch_related("property_group__properties") + .order_by("-created_at") + ) diff --git a/posthog/api/schema_property_group.py b/posthog/api/schema_property_group.py new file mode 100644 index 0000000000..c706a98b73 --- /dev/null +++ b/posthog/api/schema_property_group.py @@ -0,0 +1,173 @@ +import re +import logging + +from django.db import IntegrityError, transaction + +from rest_framework import mixins, serializers, viewsets + +from posthog.api.routing import TeamAndOrgViewSetMixin +from posthog.api.shared import UserBasicSerializer +from posthog.models import SchemaPropertyGroup, SchemaPropertyGroupProperty + +PROPERTY_NAME_REGEX = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +class SchemaPropertyGroupPropertySerializer(serializers.ModelSerializer): + class Meta: + model = SchemaPropertyGroupProperty + fields = ( + "id", + "name", + "property_type", + "is_required", + "description", + "created_at", + "updated_at", + ) + read_only_fields = ("id", "created_at", "updated_at") + + def validate_name(self, value: str) -> str: + if not value or not value.strip(): + raise serializers.ValidationError("Property name is required") + + cleaned_value = value.strip() + if not PROPERTY_NAME_REGEX.match(cleaned_value): + raise serializers.ValidationError( + "Property name must start with a letter or underscore and contain only letters, numbers, and underscores" + ) + + return cleaned_value + + +class EventDefinitionBasicSerializer(serializers.Serializer): + id = serializers.UUIDField() + name = serializers.CharField() + + +class SchemaPropertyGroupSerializer(serializers.ModelSerializer): + properties = SchemaPropertyGroupPropertySerializer(many=True, required=False) + created_by = UserBasicSerializer(read_only=True) + events = serializers.SerializerMethodField() + + class Meta: + model = SchemaPropertyGroup + fields = ( + "id", + "name", + "description", + "properties", + "events", + "created_at", + "updated_at", + "created_by", + ) + read_only_fields = ("id", "created_at", "updated_at", "created_by") + + def get_events(self, obj): + event_schemas = obj.event_schemas.select_related("event_definition").all() + event_definitions = sorted([es.event_definition for es in event_schemas], key=lambda e: e.name.lower()) + return EventDefinitionBasicSerializer(event_definitions, many=True).data + + def create(self, validated_data): + properties_data = validated_data.pop("properties", []) + request = self.context.get("request") + + try: + property_group = SchemaPropertyGroup.objects.create( + **validated_data, + team_id=self.context["team_id"], + project_id=self.context["project_id"], + created_by=request.user if request else None, + ) + + for property_data in properties_data: + SchemaPropertyGroupProperty.objects.create(property_group=property_group, **property_data) + + return property_group + except IntegrityError as e: + if "unique_schema_property_group_team_name" in str(e): + raise serializers.ValidationError( + {"name": "A property group with this name already exists for this team"} + ) + logging.error(f"Database integrity error while creating property group: {e}", exc_info=True) + raise serializers.ValidationError("Could not create property group due to a database error.") + + def update(self, instance, validated_data): + properties_data = validated_data.pop("properties", None) + + try: + with transaction.atomic(): + instance.name = validated_data.get("name", instance.name) + instance.description = validated_data.get("description", instance.description) + instance.save() + + if properties_data is not None: + existing_properties = {prop.id: prop for prop in instance.properties.all()} + incoming_property_ids = {prop.get("id") for prop in properties_data if prop.get("id")} + + # Delete properties that are no longer present + properties_to_delete = set(existing_properties.keys()) - incoming_property_ids + if properties_to_delete: + SchemaPropertyGroupProperty.objects.filter(id__in=properties_to_delete).delete() + + # Update existing properties and create new ones + for property_data in properties_data: + property_id = property_data.pop("id", None) + if property_id and property_id in existing_properties: + # Update existing property + existing_prop = existing_properties[property_id] + for key, value in property_data.items(): + setattr(existing_prop, key, value) + existing_prop.save() + else: + # Create new property + SchemaPropertyGroupProperty.objects.create(property_group=instance, **property_data) + + # Query fresh instance with properties to ensure all data is current + return SchemaPropertyGroup.objects.prefetch_related("properties").get(pk=instance.pk) + except IntegrityError as e: + error_str = str(e) + + # Handle duplicate property name within group + if "unique_property_group_property_name" in error_str: + # Extract the property name from the error message + import re + + match = re.search(r"\(property_group_id, name\)=\([^,]+, ([^)]+)\)", error_str) + if match: + property_name = match.group(1) + raise serializers.ValidationError( + {"properties": f"A property named '{property_name}' already exists in this group"} + ) + raise serializers.ValidationError( + {"properties": "A property with this name already exists in this group"} + ) + + # Handle duplicate property group name + if "unique_schema_property_group_team_name" in error_str: + raise serializers.ValidationError({"name": "A property group with this name already exists"}) + + logging.error(f"Database integrity error while updating property group: {e}", exc_info=True) + raise serializers.ValidationError("Could not update property group due to a database error.") + + +class SchemaPropertyGroupViewSet( + TeamAndOrgViewSetMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + scope_object = "INTERNAL" + serializer_class = SchemaPropertyGroupSerializer + queryset = SchemaPropertyGroup.objects.all() + lookup_field = "id" + + def safely_get_queryset(self, queryset): + return ( + queryset.filter(team_id=self.team_id) + .prefetch_related("properties", "event_schemas__event_definition") + .order_by("name") + ) diff --git a/posthog/api/test/test_schema_property_group.py b/posthog/api/test/test_schema_property_group.py new file mode 100644 index 0000000000..81c361b77a --- /dev/null +++ b/posthog/api/test/test_schema_property_group.py @@ -0,0 +1,248 @@ +from posthog.test.base import APIBaseTest + +from rest_framework import status + +from posthog.models import EventDefinition, EventSchema, Project, SchemaPropertyGroup, SchemaPropertyGroupProperty + + +class TestSchemaPropertyGroupAPI(APIBaseTest): + def test_create_property_group_with_properties(self): + response = self.client.post( + f"/api/projects/{self.project.id}/schema_property_groups/", + { + "name": "User Info", + "description": "Basic user information", + "properties": [ + { + "name": "user_id", + "property_type": "String", + "is_required": True, + "description": "User ID", + }, + { + "name": "email", + "property_type": "String", + "is_required": False, + "description": "Email", + }, + ], + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "User Info" + assert data["description"] == "Basic user information" + assert len(data["properties"]) == 2 + assert data["properties"][0]["name"] == "email" + assert data["properties"][0]["is_required"] is False + assert data["properties"][1]["name"] == "user_id" + assert data["properties"][1]["is_required"] is True + + def test_update_property_group_preserves_existing_properties(self): + # Create initial property group + property_group = SchemaPropertyGroup.objects.create( + team=self.team, project=self.project, name="Test Group", description="Test" + ) + prop1 = SchemaPropertyGroupProperty.objects.create( + property_group=property_group, name="prop1", property_type="String" + ) + prop2 = SchemaPropertyGroupProperty.objects.create( + property_group=property_group, name="prop2", property_type="Numeric" + ) + + # Update: keep prop1, modify prop2, add prop3, delete nothing + response = self.client.patch( + f"/api/projects/{self.project.id}/schema_property_groups/{property_group.id}/", + { + "properties": [ + {"id": str(prop1.id), "name": "prop1", "property_type": "String"}, + {"id": str(prop2.id), "name": "prop2_updated", "property_type": "Numeric"}, + {"name": "prop3", "property_type": "Boolean"}, + ] + }, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["properties"]) == 3 + + # Properties are recreated with new IDs, check by name and values + prop_names = {p["name"] for p in data["properties"]} + assert "prop1" in prop_names + assert "prop2_updated" in prop_names + assert "prop3" in prop_names + + # Verify types are correct + prop2_updated = next(p for p in data["properties"] if p["name"] == "prop2_updated") + assert prop2_updated["property_type"] == "Numeric" + prop3 = next(p for p in data["properties"] if p["name"] == "prop3") + assert prop3["property_type"] == "Boolean" + + def test_update_property_group_deletes_removed_properties(self): + # Create property group with 2 properties + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + prop1 = SchemaPropertyGroupProperty.objects.create( + property_group=property_group, name="prop1", property_type="String" + ) + SchemaPropertyGroupProperty.objects.create(property_group=property_group, name="prop2", property_type="Numeric") + + # Update to only keep prop1 + response = self.client.patch( + f"/api/projects/{self.project.id}/schema_property_groups/{property_group.id}/", + { + "properties": [ + {"id": str(prop1.id), "name": "prop1", "property_type": "String"}, + ] + }, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["properties"]) == 1 + assert data["properties"][0]["name"] == "prop1" + + def test_unique_constraint_on_team_and_name(self): + SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Duplicate Name") + + response = self.client.post( + f"/api/projects/{self.project.id}/schema_property_groups/", + {"name": "Duplicate Name", "description": "Should fail"}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "already exists" in str(response.json()) + + def test_property_name_validation(self): + response = self.client.post( + f"/api/projects/{self.project.id}/schema_property_groups/", + { + "name": "Test Group", + "properties": [ + {"name": "123invalid", "property_type": "String"}, + ], + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "must start with a letter or underscore" in str(response.json()) + + def test_delete_property_group(self): + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="To Delete") + SchemaPropertyGroupProperty.objects.create(property_group=property_group, name="prop1", property_type="String") + + response = self.client.delete(f"/api/projects/{self.project.id}/schema_property_groups/{property_group.id}/") + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not SchemaPropertyGroup.objects.filter(id=property_group.id).exists() + # Properties should be cascade deleted + assert not SchemaPropertyGroupProperty.objects.filter(property_group=property_group).exists() + + def test_list_includes_events(self): + # Create property group + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + + # Create event and associate it + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + EventSchema.objects.create(event_definition=event_def, property_group=property_group) + + response = self.client.get(f"/api/projects/{self.project.id}/schema_property_groups/") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + property_groups = data["results"] if "results" in data else data + + test_group = next((pg for pg in property_groups if pg["id"] == str(property_group.id)), None) + assert test_group is not None + assert "events" in test_group + assert len(test_group["events"]) == 1 + assert test_group["events"][0]["name"] == "test_event" + + +class TestEventSchemaAPI(APIBaseTest): + def test_create_event_schema(self): + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + + response = self.client.post( + f"/api/projects/{self.project.id}/event_schemas/", + { + "event_definition": event_def.id, + "property_group_id": str(property_group.id), + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["property_group"]["id"] == str(property_group.id) + + def test_prevent_duplicate_event_schema(self): + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + + # Create first event schema + EventSchema.objects.create(event_definition=event_def, property_group=property_group) + + # Try to create duplicate + response = self.client.post( + f"/api/projects/{self.project.id}/event_schemas/", + { + "event_definition": event_def.id, + "property_group_id": str(property_group.id), + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + # Check that it's a uniqueness error + response_data = response.json() + assert "unique" in str(response_data).lower() + + def test_delete_event_schema(self): + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + event_schema = EventSchema.objects.create(event_definition=event_def, property_group=property_group) + + response = self.client.delete(f"/api/projects/{self.project.id}/event_schemas/{event_schema.id}/") + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not EventSchema.objects.filter(id=event_schema.id).exists() + + def test_cross_team_property_group_rejection(self): + other_project, other_team = Project.objects.create_with_team( + organization=self.organization, + name="Other Project", + initiating_user=self.user, + team_fields={"name": "Other Team"}, + ) + other_property_group = SchemaPropertyGroup.objects.create( + team=other_team, project=other_project, name="Other Team Group" + ) + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + + response = self.client.post( + f"/api/projects/{self.project.id}/event_schemas/", + {"event_definition": event_def.id, "property_group_id": str(other_property_group.id)}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "does not exist" in str(response.json()) + + def test_cross_team_event_definition_rejection(self): + other_project, other_team = Project.objects.create_with_team( + organization=self.organization, + name="Other Project", + initiating_user=self.user, + team_fields={"name": "Other Team"}, + ) + other_event_def = EventDefinition.objects.create( + team=other_team, project=other_project, name="other_team_event" + ) + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + + response = self.client.post( + f"/api/projects/{self.project.id}/event_schemas/", + {"event_definition": other_event_def.id, "property_group_id": str(property_group.id)}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "does not exist" in str(response.json()) diff --git a/posthog/demo/products/hedgebox/matrix.py b/posthog/demo/products/hedgebox/matrix.py index df95576580..17c763e3ca 100644 --- a/posthog/demo/products/hedgebox/matrix.py +++ b/posthog/demo/products/hedgebox/matrix.py @@ -45,6 +45,9 @@ from posthog.constants import PAGEVIEW_EVENT from posthog.demo.matrix.matrix import Cluster, Matrix from posthog.demo.matrix.randomization import Industry from posthog.models import Action, Cohort, Dashboard, DashboardTile, Experiment, FeatureFlag, Insight, InsightViewed +from posthog.models.event_definition import EventDefinition +from posthog.models.property_definition import PropertyType +from posthog.models.schema import EventSchema, SchemaPropertyGroup, SchemaPropertyGroupProperty from .models import HedgeboxAccount, HedgeboxPerson from .taxonomy import ( @@ -947,3 +950,56 @@ class HedgeboxMatrix(Matrix): ) ] team.revenue_analytics_config.save() + + # Create File Stats property group + try: + file_stats_group = SchemaPropertyGroup.objects.create( + team=team, + project=team.project, + name="File Stats", + description="", + created_by=user, + ) + + SchemaPropertyGroupProperty.objects.create( + property_group=file_stats_group, + name="file_size_b", + property_type=PropertyType.Numeric, + is_required=True, + description="", + ) + + SchemaPropertyGroupProperty.objects.create( + property_group=file_stats_group, + name="file_type", + property_type=PropertyType.String, + is_required=False, + description="", + ) + + SchemaPropertyGroupProperty.objects.create( + property_group=file_stats_group, + name="file_name", + property_type=PropertyType.String, + is_required=False, + description="", + ) + + uploaded_file_def = EventDefinition.objects.get_or_create( + team=team, name=EVENT_UPLOADED_FILE, defaults={"team": team} + )[0] + downloaded_file_def = EventDefinition.objects.get_or_create( + team=team, name=EVENT_DOWNLOADED_FILE, defaults={"team": team} + )[0] + + EventSchema.objects.create( + event_definition=uploaded_file_def, + property_group=file_stats_group, + ) + + EventSchema.objects.create( + event_definition=downloaded_file_def, + property_group=file_stats_group, + ) + except IntegrityError: + pass diff --git a/posthog/demo/products/hedgebox/models.py b/posthog/demo/products/hedgebox/models.py index 548112e600..7a1b494b3e 100644 --- a/posthog/demo/products/hedgebox/models.py +++ b/posthog/demo/products/hedgebox/models.py @@ -582,7 +582,11 @@ class HedgeboxPerson(SimPerson): if self.cluster.random.random() < 0.7: self.active_client.capture( EVENT_DOWNLOADED_FILE, - {"file_type": file.type, "file_size_b": file.size_b}, + { + "file_type": file.type, + "file_size_b": file.size_b, + "file_name": self.cluster.random.randbytes(8).hex(), + }, ) self.advance_timer(0.5 + self.cluster.random.betavariate(1.2, 2) * 80) self.need += (self.cluster.random.betavariate(1.2, 1) - 0.5) * 0.08 @@ -684,14 +688,14 @@ class HedgeboxPerson(SimPerson): assert self.account is not None self.advance_timer(self.cluster.random.betavariate(2.5, 1.1) * 95) self.account.files.add(file) - self.active_client.capture( - EVENT_UPLOADED_FILE, - properties={ - "file_type": file.type, - "file_size_b": file.size_b, - "used_mb": self.account.current_used_mb, - }, - ) + properties = { + "file_type": file.type, + "file_size_b": file.size_b, + "used_mb": self.account.current_used_mb, + } + if self.cluster.random.random() < 0.5: + properties["file_name"] = self.cluster.random.randbytes(8).hex() + self.active_client.capture(EVENT_UPLOADED_FILE, properties=properties) self.active_client.group( GROUP_TYPE_ACCOUNT, self.account.id, @@ -709,7 +713,14 @@ class HedgeboxPerson(SimPerson): ) def download_file(self, file: HedgeboxFile): - self.active_client.capture(EVENT_DOWNLOADED_FILE, {"file_type": file.type, "file_size_b": file.size_b}) + self.active_client.capture( + EVENT_DOWNLOADED_FILE, + { + "file_type": file.type, + "file_size_b": file.size_b, + "file_name": self.cluster.random.randbytes(8).hex(), + }, + ) def delete_file(self, file: HedgeboxFile): assert self.account is not None diff --git a/posthog/migrations/0889_add_schema_models.py b/posthog/migrations/0889_add_schema_models.py new file mode 100644 index 0000000000..c7ff5b9e23 --- /dev/null +++ b/posthog/migrations/0889_add_schema_models.py @@ -0,0 +1,155 @@ +# Generated by Django 4.2.22 on 2025-10-02 22:57 + +import django.utils.timezone +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import posthog.models.utils +import posthog.models.property_definition + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("posthog", "0888_datawarehousemanagedviewset_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SchemaPropertyGroup", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("name", models.CharField(max_length=400)), + ("description", models.TextField(blank=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_schema_property_groups", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="posthog.project"), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="schema_property_groups", + related_query_name="schema_property_group", + to="posthog.team", + ), + ), + ], + ), + migrations.CreateModel( + name="EventSchema", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "event_definition", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="schemas", + related_query_name="schema", + to="posthog.eventdefinition", + ), + ), + ( + "property_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_schemas", + related_query_name="event_schema", + to="posthog.schemapropertygroup", + ), + ), + ], + ), + migrations.CreateModel( + name="SchemaPropertyGroupProperty", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("name", models.CharField(max_length=400)), + ( + "property_type", + models.CharField( + choices=posthog.models.property_definition.PropertyType.choices, + max_length=50, + ), + ), + ("is_required", models.BooleanField(default=False)), + ("description", models.TextField(blank=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "property_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="properties", + related_query_name="property", + to="posthog.schemapropertygroup", + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.AddIndex( + model_name="schemapropertygroupproperty", + index=models.Index(fields=["property_group", "name"], name="schema_pgp_group_name_idx"), + ), + migrations.AddConstraint( + model_name="schemapropertygroupproperty", + constraint=models.UniqueConstraint( + fields=("property_group", "name"), name="unique_property_group_property_name" + ), + ), + migrations.AddConstraint( + model_name="schemapropertygroupproperty", + constraint=models.CheckConstraint( + check=models.Q(("property_type__in", posthog.models.property_definition.PropertyType.values)), + name="property_type_is_valid_schema", + ), + ), + migrations.AddIndex( + model_name="schemapropertygroup", + index=models.Index(fields=["team", "name"], name="schema_pg_team_name_idx"), + ), + migrations.AddConstraint( + model_name="schemapropertygroup", + constraint=models.UniqueConstraint(fields=("team", "name"), name="unique_schema_property_group_team_name"), + ), + migrations.AddConstraint( + model_name="eventschema", + constraint=models.UniqueConstraint( + fields=("event_definition", "property_group"), name="unique_event_schema" + ), + ), + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index 25250d9bc8..63e139b3be 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -0888_datawarehousemanagedviewset_and_more +0889_add_schema_models diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index f854dd816b..1b8798d210 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -68,6 +68,7 @@ from .property_definition import PropertyDefinition from .proxy_record import ProxyRecord from .remote_config import RemoteConfig from .scheduled_change import ScheduledChange +from .schema import EventSchema, SchemaPropertyGroup, SchemaPropertyGroupProperty from .share_password import SharePassword from .sharing_configuration import SharingConfiguration from .subscription import Subscription @@ -169,6 +170,9 @@ __all__ = [ "ProxyRecord", "RetentionFilter", "RemoteConfig", + "EventSchema", + "SchemaPropertyGroup", + "SchemaPropertyGroupProperty", "SessionRecording", "SessionRecordingPlaylist", "SessionRecordingPlaylistItem", diff --git a/posthog/models/schema.py b/posthog/models/schema.py new file mode 100644 index 0000000000..e8bc8a7d06 --- /dev/null +++ b/posthog/models/schema.py @@ -0,0 +1,123 @@ +from django.db import models +from django.utils import timezone + +from posthog.models.event_definition import EventDefinition +from posthog.models.property_definition import PropertyType +from posthog.models.team import Team +from posthog.models.utils import UUIDTModel + + +class SchemaPropertyGroup(UUIDTModel): + """ + A reusable group of properties that defines a schema. + Can be attached to multiple events via EventSchema. + """ + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="schema_property_groups", + related_query_name="schema_property_group", + ) + project = models.ForeignKey("Project", on_delete=models.CASCADE, null=True) + name = models.CharField(max_length=400) + description = models.TextField(blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_schema_property_groups", + ) + + class Meta: + indexes = [ + models.Index(fields=["team", "name"], name="schema_pg_team_name_idx"), + ] + constraints = [ + models.UniqueConstraint( + fields=["team", "name"], + name="unique_schema_property_group_team_name", + ) + ] + + def __str__(self) -> str: + return f"{self.name} / {self.team.name}" + + +class SchemaPropertyGroupProperty(UUIDTModel): + """ + Individual property within a property group. + Defines the expected name, type, and whether it's required. + """ + + property_group = models.ForeignKey( + SchemaPropertyGroup, + on_delete=models.CASCADE, + related_name="properties", + related_query_name="property", + ) + name = models.CharField(max_length=400) + property_type = models.CharField(max_length=50, choices=PropertyType.choices) + is_required = models.BooleanField(default=False) + description = models.TextField(blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + models.Index( + fields=["property_group", "name"], + name="schema_pgp_group_name_idx", + ), + ] + constraints = [ + models.UniqueConstraint( + fields=["property_group", "name"], + name="unique_property_group_property_name", + ), + models.CheckConstraint( + name="property_type_is_valid_schema", + check=models.Q(property_type__in=PropertyType.values), + ), + ] + ordering = ["name"] + + def __str__(self) -> str: + required_str = " (required)" if self.is_required else "" + return f"{self.name} ({self.property_type}){required_str} / {self.property_group.name}" + + +class EventSchema(UUIDTModel): + """ + Associates a property group with an event definition. + Defines which property groups an event should have. + """ + + event_definition = models.ForeignKey( + EventDefinition, + on_delete=models.CASCADE, + related_name="schemas", + related_query_name="schema", + ) + property_group = models.ForeignKey( + SchemaPropertyGroup, + on_delete=models.CASCADE, + related_name="event_schemas", + related_query_name="event_schema", + ) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["event_definition", "property_group"], + name="unique_event_schema", + ) + ] + + def __str__(self) -> str: + return f"{self.event_definition.name} → {self.property_group.name}" diff --git a/products/actions/manifest.tsx b/products/actions/manifest.tsx index 9905350ddc..4225a6d678 100644 --- a/products/actions/manifest.tsx +++ b/products/actions/manifest.tsx @@ -70,7 +70,7 @@ export const manifest: ProductManifest = { treeItemsMetadata: [ { path: 'Actions', - category: 'Definitions', + category: 'Schema', href: urls.actions(), iconType: 'action' as FileSystemIconType, sceneKey: 'Actions', diff --git a/products/revenue_analytics/manifest.tsx b/products/revenue_analytics/manifest.tsx index 2e88fe7318..050a919fda 100644 --- a/products/revenue_analytics/manifest.tsx +++ b/products/revenue_analytics/manifest.tsx @@ -42,8 +42,8 @@ export const manifest: ProductManifest = { }, treeItemsMetadata: [ { - path: 'Revenue settings', - category: 'Definitions', + path: 'Revenue definitions', + category: 'Schema', iconType: 'revenue_analytics_metadata' as FileSystemIconType, href: urls.revenueSettings(), sceneKey: 'RevenueAnalytics',