mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
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:
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}),
|
||||
])
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
225
frontend/src/scenes/data-management/schema/SchemaManagement.tsx
Normal file
225
frontend/src/scenes/data-management/schema/SchemaManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}),
|
||||
])
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 |
@@ -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"])
|
||||
|
||||
|
||||
98
posthog/api/event_schema.py
Normal file
98
posthog/api/event_schema.py
Normal 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")
|
||||
)
|
||||
173
posthog/api/schema_property_group.py
Normal file
173
posthog/api/schema_property_group.py
Normal 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")
|
||||
)
|
||||
248
posthog/api/test/test_schema_property_group.py
Normal file
248
posthog/api/test/test_schema_property_group.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
155
posthog/migrations/0889_add_schema_models.py
Normal file
155
posthog/migrations/0889_add_schema_models.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1 +1 @@
|
||||
0888_datawarehousemanagedviewset_and_more
|
||||
0889_add_schema_models
|
||||
|
||||
@@ -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
123
posthog/models/schema.py
Normal 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}"
|
||||
@@ -70,7 +70,7 @@ export const manifest: ProductManifest = {
|
||||
treeItemsMetadata: [
|
||||
{
|
||||
path: 'Actions',
|
||||
category: 'Definitions',
|
||||
category: 'Schema',
|
||||
href: urls.actions(),
|
||||
iconType: 'action' as FileSystemIconType,
|
||||
sceneKey: 'Actions',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user