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 (
+
+
+
+ )
+}
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 ? (
+ <>
+
+ {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',