feat: ui & crud for schema management (#39288)

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Sandy Spicer
2025-10-21 12:30:07 -07:00
committed by GitHub
parent df5d5d0048
commit 918f1ff3a2
28 changed files with 2233 additions and 18 deletions

View File

@@ -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',

View File

@@ -48,7 +48,7 @@ export function SceneSection({
</Component>
{description && <p className="text-sm text-secondary my-0 max-w-prose">{description}</p>}
</div>
{actions && <div className="flex gap-x-2 flex-none self-center">{actions}</div>}
{actions && <div className="flex gap-x-2 flex-none self-end">{actions}</div>}
</div>
<WrappingLoadingSkeleton>{children}</WrappingLoadingSkeleton>
</div>
@@ -77,7 +77,7 @@ export function SceneSection({
</Component>
{description && <p className="text-sm text-secondary my-0 max-w-prose">{description}</p>}
</div>
{actions && <div className="flex gap-x-2 flex-none self-center">{actions}</div>}
{actions && <div className="flex gap-x-2 flex-none self-end">{actions}</div>}
</div>
)}
{children}

View File

@@ -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<SchemaPropertyGroup> {
return new ApiRequest().schemaPropertyGroupsDetail(id, projectId).get()
},
async create(data: Partial<SchemaPropertyGroup>, projectId?: ProjectType['id']): Promise<SchemaPropertyGroup> {
return new ApiRequest().schemaPropertyGroups(projectId).create({ data })
},
async update(
id: string,
data: Partial<SchemaPropertyGroup>,
projectId?: ProjectType['id']
): Promise<SchemaPropertyGroup> {
return new ApiRequest().schemaPropertyGroupsDetail(id, projectId).update({ data })
},
async delete(id: string, projectId?: ProjectType['id']): Promise<void> {
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<EventSchema>, projectId?: ProjectType['id']): Promise<EventSchema> {
return new ApiRequest().eventSchemas(projectId).create({ data })
},
async delete(id: string, projectId?: ProjectType['id']): Promise<void> {
return new ApiRequest().eventSchemasDetail(id, projectId).delete()
},
},
cohorts: {
async get(cohortId: CohortType['id']): Promise<CohortType> {
return await new ApiRequest().cohortsDetail(cohortId).get()

View File

@@ -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

View File

@@ -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<DataManagementTab, TabConfig> = {
content: <PropertyDefinitionsTable />,
tooltipDocLink: 'https://posthog.com/docs/new-to-posthog/understand-posthog#properties',
},
[DataManagementTab.SchemaManagement]: {
url: urls.schemaManagement(),
label: 'Property Groups',
content: <SchemaManagement />,
flag: FEATURE_FLAGS.SCHEMA_MANAGEMENT,
},
[DataManagementTab.Annotations]: {
url: urls.annotations(),
content: <Annotations />,

View File

@@ -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' && (
<>
<FlaggedFeature flag={FEATURE_FLAGS.SCHEMA_MANAGEMENT}>
<EventDefinitionSchema definition={definition} />
<SceneDivider />
</FlaggedFeature>
<EventDefinitionProperties definition={definition} />
<SceneDivider />
<EventDefinitionInsights definition={definition} />

View File

@@ -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 (
<div className="flex items-center gap-4 py-3 px-4 border-b last:border-b-0 bg-white">
<div className="flex-1">
<span className="font-semibold">{property.name}</span>
</div>
<div className="w-32">
<LemonTag type="muted">{property.property_type}</LemonTag>
</div>
<div className="w-24">
{property.is_required ? (
<LemonTag type="danger">Required</LemonTag>
) : (
<LemonTag type="muted">Optional</LemonTag>
)}
</div>
<div className="flex-1 text-muted">{property.description || '—'}</div>
</div>
)
}
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<Set<string>>(
() => new Set(eventSchemas.map((schema: EventSchema) => schema.property_group.id)),
[eventSchemas]
)
return (
<SceneSection
title="Schema"
description="Define which property groups this event should have. Property groups establish a schema that helps document expected properties."
actions={
<LemonButton
type="primary"
icon={<IconPlus />}
onClick={() => setIsModalOpen(true)}
disabled={eventSchemasLoading}
>
Add Property Group
</LemonButton>
}
>
<div className="space-y-4">
<SelectPropertyGroupModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSelect={(propertyGroupId) => addPropertyGroup(propertyGroupId)}
selectedPropertyGroupIds={selectedPropertyGroupIds}
onPropertyGroupCreated={() => {
loadAllPropertyGroups()
}}
/>
{eventSchemas.length > 0 ? (
<div className="space-y-4">
{eventSchemas.map((schema: EventSchema) => (
<div key={schema.id} className="border rounded overflow-hidden">
<div className="flex items-center justify-between p-4 bg-bg-light border-b">
<div className="flex items-center gap-2">
<span className="font-semibold">{schema.property_group.name}</span>
<LemonTag type="default">
{schema.property_group.properties?.length || 0}{' '}
{schema.property_group.properties?.length === 1 ? 'property' : 'properties'}
</LemonTag>
</div>
<div className="flex gap-1">
<LemonButton
icon={<IconPencil />}
size="small"
onClick={() => {
setEditingPropertyGroup(schema.property_group)
setPropertyGroupModalOpen(true)
}}
tooltip="Edit this property group"
/>
<LemonButton
icon={<IconTrash />}
size="small"
status="danger"
onClick={() => removePropertyGroup(schema.id)}
tooltip="Remove this property group from the event schema"
/>
</div>
</div>
{schema.property_group.properties && schema.property_group.properties.length > 0 && (
<>
<div className="flex gap-4 py-2 px-4 bg-accent-3000 border-b text-xs font-semibold uppercase tracking-wider">
<div className="flex-1">Property</div>
<div className="w-32">Type</div>
<div className="w-24">Required</div>
<div className="flex-1">Description</div>
</div>
{schema.property_group.properties.map(
(property: SchemaPropertyGroupProperty) => (
<PropertyRow key={property.id} property={property} />
)
)}
</>
)}
</div>
))}
</div>
) : (
<div className="text-center text-muted py-8 border rounded bg-bg-light">
No property groups added yet. Add a property group above to define the schema for this event.
</div>
)}
</div>
<PropertyGroupModal logicKey={`event-${definition.id}`} />
</SceneSection>
)
}

View File

@@ -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<typeof eventDefinitionSchemaLogic.build>
let schemaLogic: ReturnType<typeof schemaManagementLogic.build>
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()
})
})

View File

@@ -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<eventDefinitionSchemaLogicType>([
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()
}),
])

View File

@@ -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<void>
}
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<void> => {
await onAfterSave?.()
}
const columns: LemonTableColumns<SchemaPropertyGroupProperty> = [
{
title: 'Name',
key: 'name',
render: (_, property, index) => (
<LemonInput
value={property.name}
onChange={(value) => updatePropertyInForm(index, { name: value })}
placeholder="Property name"
status={!isValidPropertyName(property.name) ? 'danger' : undefined}
fullWidth
/>
),
},
{
title: 'Type',
key: 'property_type',
width: 150,
render: (_, property, index) => (
<LemonSelect
value={property.property_type}
onChange={(value) => updatePropertyInForm(index, { property_type: value as PropertyType })}
options={PROPERTY_TYPE_OPTIONS}
fullWidth
/>
),
},
{
title: 'Required',
key: 'is_required',
width: 100,
align: 'center',
render: (_, property, index) => (
<div
className="flex justify-center items-center cursor-pointer h-full py-2 -my-2"
onClick={() => updatePropertyInForm(index, { is_required: !property.is_required })}
>
<LemonCheckbox
checked={property.is_required}
onChange={(checked) => updatePropertyInForm(index, { is_required: checked })}
/>
</div>
),
},
{
title: 'Description',
key: 'description',
render: (_, property, index) => (
<LemonInput
value={property.description}
onChange={(value) => updatePropertyInForm(index, { description: value })}
placeholder="Optional description"
fullWidth
/>
),
},
{
key: 'actions',
width: 50,
render: (_, _property, index) => (
<LemonButton icon={<IconTrash />} size="small" onClick={() => removePropertyFromForm(index)} />
),
},
]
return (
<LemonModal
isOpen={propertyGroupModalOpen}
onClose={handleClose}
title={editingPropertyGroup ? 'Edit Property Group' : 'New Property Group'}
width={900}
>
<Form
logic={schemaManagementLogic}
props={{ key: logicKey || 'default' }}
formKey="propertyGroupForm"
enableFormOnSubmit
className="space-y-4"
>
<LemonField name="name" label="Name">
<LemonInput placeholder="e.g., Order, Product, User" autoFocus />
</LemonField>
<LemonField name="description" label="Description">
<LemonTextArea placeholder="Describe what this property group represents" rows={2} />
</LemonField>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-base font-semibold mb-0">Properties</h3>
<LemonButton type="secondary" icon={<IconPlus />} size="small" onClick={addPropertyToForm}>
Add property
</LemonButton>
</div>
{propertyGroupForm.properties.length > 0 ? (
<>
<LemonTable
columns={columns}
dataSource={propertyGroupForm.properties}
pagination={undefined}
/>
{propertyGroupFormValidationError && (
<div className="text-danger text-sm mt-2">{propertyGroupFormValidationError}</div>
)}
</>
) : (
<div className="text-center text-muted py-6">
No properties yet. Click "Add property" to get started.
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<LemonButton type="secondary" onClick={handleClose}>
Cancel
</LemonButton>
<LemonButton
type="primary"
htmlType="submit"
loading={isPropertyGroupFormSubmitting}
disabledReason={propertyGroupFormValidationError || undefined}
onClick={async () => {
await submitPropertyGroupForm()
await handleAfterSubmit()
}}
>
{editingPropertyGroup ? 'Update' : 'Create'}
</LemonButton>
</div>
</Form>
</LemonModal>
)
}

View File

@@ -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 (
<div className="py-3 px-4 border-b last:border-b-0 bg-white">
<Link to={urls.eventDefinition(event.id)} className="font-semibold">
{event.name}
</Link>
</div>
)
}
function PropertyRow({ property }: { property: SchemaPropertyGroupProperty }): JSX.Element {
return (
<div className="flex items-center gap-4 py-3 px-4 border-b last:border-b-0 bg-white">
<div className="flex-1">
<Link
to={`${urls.propertyDefinitions()}?property=${encodeURIComponent(property.name)}`}
className="font-semibold"
>
{property.name}
</Link>
</div>
<div className="w-32">
<LemonTag type="muted">{property.property_type}</LemonTag>
</div>
<div className="w-24">
{property.is_required ? (
<LemonTag type="danger">Required</LemonTag>
) : (
<LemonTag type="muted">Optional</LemonTag>
)}
</div>
<div className="flex-1 text-muted">{property.description || '—'}</div>
</div>
)
}
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 <NotFound object="page" caption="Schema management is not available." />
}
const columns: LemonTableColumns<SchemaPropertyGroup> = [
{
key: 'expander',
width: 0,
},
{
title: 'Name',
key: 'name',
dataIndex: 'name',
render: (name) => <span className="font-semibold">{name}</span>,
},
{
title: 'Description',
key: 'description',
dataIndex: 'description',
render: (description) => <span className="text-muted">{description || '—'}</span>,
},
{
title: 'Properties',
key: 'property_count',
width: 120,
render: (_, propertyGroup) => (
<LemonTag type="default">
{propertyGroup.properties?.length || 0}{' '}
{propertyGroup.properties?.length === 1 ? 'property' : 'properties'}
</LemonTag>
),
},
{
key: 'actions',
width: 100,
render: (_, propertyGroup) => (
<div className="flex gap-1">
<LemonButton
icon={<IconPencil />}
size="small"
onClick={() => {
setEditingPropertyGroup(propertyGroup)
setPropertyGroupModalOpen(true)
}}
/>
<LemonButton
icon={<IconTrash />}
size="small"
status="danger"
onClick={() => {
if (
confirm(
`Are you sure you want to delete "${propertyGroup.name}"? This action cannot be undone.`
)
) {
deletePropertyGroup(propertyGroup.id)
}
}}
/>
</div>
),
},
]
return (
<SceneContent>
<SceneTitleSection
name="Property Groups"
description="Define reusable property groups to establish schemas for your events."
resourceType={{
type: 'schema',
forceIcon: <IconApps />,
}}
/>
<SceneDivider />
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<LemonInput
type="search"
placeholder="Search property groups..."
className="max-w-60"
value={searchTerm}
onChange={setSearchTerm}
/>
<LemonButton
type="primary"
icon={<IconPlus />}
onClick={() => {
setEditingPropertyGroup(null)
setPropertyGroupModalOpen(true)
}}
>
New Property Group
</LemonButton>
</div>
<LemonTable
columns={columns}
dataSource={filteredPropertyGroups}
loading={propertyGroupsLoading}
rowKey="id"
expandable={{
expandedRowRender: (propertyGroup) => (
<div className="space-y-4 mx-4 mb-2 mt-2">
{/* Events Section */}
<div>
<h3 className="text-sm font-semibold mb-2">Events</h3>
<div className="border rounded overflow-hidden">
{propertyGroup.events && propertyGroup.events.length > 0 ? (
<>
<div className="flex gap-4 py-2 px-4 bg-accent-3000 border-b text-xs font-semibold uppercase tracking-wider">
<div className="flex-1">Event Name</div>
</div>
{propertyGroup.events.map((event) => (
<EventRow key={event.id} event={event} />
))}
</>
) : (
<div className="text-center text-muted py-4">
No events using this property group
</div>
)}
</div>
</div>
{/* Properties Section */}
<div>
<h3 className="text-sm font-semibold mb-2">Properties</h3>
<div className="border rounded overflow-hidden">
{propertyGroup.properties && propertyGroup.properties.length > 0 ? (
<>
<div className="flex gap-4 py-2 px-4 bg-accent-3000 border-b text-xs font-semibold uppercase tracking-wider">
<div className="flex-1">Property</div>
<div className="w-32">Type</div>
<div className="w-24">Required</div>
<div className="flex-1">Description</div>
</div>
{propertyGroup.properties.map((property) => (
<PropertyRow key={property.id} property={property} />
))}
</>
) : (
<div className="text-center text-muted py-4">No properties defined</div>
)}
</div>
</div>
</div>
),
rowExpandable: (propertyGroup) =>
(propertyGroup.events && propertyGroup.events.length > 0) ||
(propertyGroup.properties && propertyGroup.properties.length > 0),
}}
emptyState="No property groups yet. Create one to get started!"
/>
</div>
<PropertyGroupModal />
</SceneContent>
)
}

View File

@@ -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 (
<div className="flex items-center gap-4 py-3 px-4 border-b last:border-b-0 bg-white">
<div className="flex-1">
<span className="font-semibold">{property.name}</span>
</div>
<div className="w-32">
<LemonTag type="muted">{property.property_type}</LemonTag>
</div>
<div className="w-24">
{property.is_required ? (
<LemonTag type="danger">Required</LemonTag>
) : (
<LemonTag type="muted">Optional</LemonTag>
)}
</div>
<div className="flex-1 text-muted">{property.description || '—'}</div>
</div>
)
}
interface SelectPropertyGroupModalProps {
isOpen: boolean
onClose: () => void
onSelect: (propertyGroupId: string) => void
selectedPropertyGroupIds?: Set<string>
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<void> => {
await loadPropertyGroups()
onPropertyGroupCreated?.()
}
const columns: LemonTableColumns<SchemaPropertyGroup> = [
{
key: 'expander',
width: 0,
},
{
title: 'Name',
key: 'name',
dataIndex: 'name',
render: (name) => <span className="font-semibold">{name}</span>,
},
{
title: 'Description',
key: 'description',
dataIndex: 'description',
render: (description) => <span className="text-muted">{description || '—'}</span>,
},
{
title: 'Properties',
key: 'property_count',
width: 120,
render: (_, propertyGroup) => (
<LemonTag type="default">
{propertyGroup.properties?.length || 0}{' '}
{propertyGroup.properties?.length === 1 ? 'property' : 'properties'}
</LemonTag>
),
},
{
key: 'actions',
width: 100,
render: (_, propertyGroup) => (
<LemonButton
type="primary"
size="small"
icon={<IconPlusSmall />}
onClick={() => {
onSelect(propertyGroup.id)
onClose()
}}
>
Add
</LemonButton>
),
},
]
return (
<>
<LemonModal isOpen={isOpen} onClose={onClose} title="Add property group" width={900}>
<div className="space-y-4">
<div className="flex gap-2">
<LemonInput
type="search"
placeholder="Search property groups..."
value={searchTerm}
onChange={setSearchTerm}
className="flex-1"
autoFocus
/>
<LemonButton type="primary" icon={<IconPlusSmall />} onClick={handleCreateNewGroup}>
New Property Group
</LemonButton>
</div>
<LemonTable
columns={columns}
dataSource={filteredPropertyGroups}
expandable={{
expandedRowRender: (propertyGroup) => (
<div className="border rounded overflow-hidden mx-4 mb-2 mt-2">
{propertyGroup.properties && propertyGroup.properties.length > 0 ? (
<>
<div className="flex gap-4 py-2 px-4 bg-accent-3000 border-b text-xs font-semibold uppercase tracking-wider">
<div className="flex-1">Property</div>
<div className="w-32">Type</div>
<div className="w-24">Required</div>
<div className="flex-1">Description</div>
</div>
{propertyGroup.properties.map((property) => (
<PropertyRow key={property.id} property={property} />
))}
</>
) : (
<div className="text-center text-muted py-4">No properties defined</div>
)}
</div>
),
rowExpandable: () => true,
}}
emptyState={
searchTerm
? 'No property groups match your search'
: 'No property groups available. Create one in Schema Management first.'
}
/>
</div>
</LemonModal>
<PropertyGroupModal logicKey="select-property-group-modal" onAfterSave={handleAfterPropertyGroupSave} />
</>
)
}

View File

@@ -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<schemaManagementLogicType>([
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<SchemaPropertyGroupProperty>) => ({ 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<SchemaPropertyGroup>) => {
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<SchemaPropertyGroup> }) => {
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()
}),
])

View File

@@ -583,6 +583,7 @@ export const routes: Record<string, [Scene | string, string]> = {
[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'],

View File

@@ -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',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -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"])

View File

@@ -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")
)

View File

@@ -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")
)

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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"
),
),
]

View File

@@ -1 +1 @@
0888_datawarehousemanagedviewset_and_more
0889_add_schema_models

View File

@@ -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",

123
posthog/models/schema.py Normal file
View File

@@ -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}"

View File

@@ -70,7 +70,7 @@ export const manifest: ProductManifest = {
treeItemsMetadata: [
{
path: 'Actions',
category: 'Definitions',
category: 'Schema',
href: urls.actions(),
iconType: 'action' as FileSystemIconType,
sceneKey: 'Actions',

View File

@@ -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',