From 918f1ff3a2ac6fbb722d355b53aec49ccad30fe7 Mon Sep 17 00:00:00 2001 From: Sandy Spicer Date: Tue, 21 Oct 2025 12:30:07 -0700 Subject: [PATCH] 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> --- .../panel-layout/ProjectTree/defaultTree.tsx | 11 +- .../layout/scenes/components/SceneSection.tsx | 4 +- frontend/src/lib/api.ts | 55 ++++ frontend/src/lib/constants.tsx | 1 + .../data-management/DataManagementScene.tsx | 8 + .../definition/DefinitionView.tsx | 7 + .../events/EventDefinitionSchema.tsx | 135 +++++++++ .../events/eventDefinitionSchemaLogic.test.ts | 113 +++++++ .../events/eventDefinitionSchemaLogic.ts | 139 +++++++++ .../schema/PropertyGroupModal.tsx | 190 ++++++++++++ .../schema/SchemaManagement.tsx | 225 ++++++++++++++ .../schema/SelectPropertyGroupModal.tsx | 169 +++++++++++ .../schema/schemaManagementLogic.ts | 282 ++++++++++++++++++ frontend/src/scenes/scenes.ts | 1 + frontend/src/scenes/urls.ts | 1 + .../__snapshots__/pageview-trends-insight.png | Bin 35919 -> 35485 bytes posthog/api/__init__.py | 14 + posthog/api/event_schema.py | 98 ++++++ posthog/api/schema_property_group.py | 173 +++++++++++ .../api/test/test_schema_property_group.py | 248 +++++++++++++++ posthog/demo/products/hedgebox/matrix.py | 56 ++++ posthog/demo/products/hedgebox/models.py | 31 +- posthog/migrations/0889_add_schema_models.py | 155 ++++++++++ posthog/migrations/max_migration.txt | 2 +- posthog/models/__init__.py | 4 + posthog/models/schema.py | 123 ++++++++ products/actions/manifest.tsx | 2 +- products/revenue_analytics/manifest.tsx | 4 +- 28 files changed, 2233 insertions(+), 18 deletions(-) create mode 100644 frontend/src/scenes/data-management/events/EventDefinitionSchema.tsx create mode 100644 frontend/src/scenes/data-management/events/eventDefinitionSchemaLogic.test.ts create mode 100644 frontend/src/scenes/data-management/events/eventDefinitionSchemaLogic.ts create mode 100644 frontend/src/scenes/data-management/schema/PropertyGroupModal.tsx create mode 100644 frontend/src/scenes/data-management/schema/SchemaManagement.tsx create mode 100644 frontend/src/scenes/data-management/schema/SelectPropertyGroupModal.tsx create mode 100644 frontend/src/scenes/data-management/schema/schemaManagementLogic.ts create mode 100644 posthog/api/event_schema.py create mode 100644 posthog/api/schema_property_group.py create mode 100644 posthog/api/test/test_schema_property_group.py create mode 100644 posthog/migrations/0889_add_schema_models.py create mode 100644 posthog/models/schema.py diff --git a/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx b/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx index eea32a6706..4711e08a84 100644 --- a/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx +++ b/frontend/src/layout/panel-layout/ProjectTree/defaultTree.tsx @@ -361,18 +361,25 @@ export const getDefaultTreeData = (): FileSystemImport[] => [ ...getTreeItemsMetadata(), { path: 'Event definitions', - category: 'Definitions', + category: 'Schema', iconType: 'event_definition', href: urls.eventDefinitions(), sceneKey: 'EventDefinition', }, { path: 'Property definitions', - category: 'Definitions', + category: 'Schema', iconType: 'property_definition', href: urls.propertyDefinitions(), sceneKey: 'PropertyDefinition', }, + { + path: 'Property groups', + category: 'Schema', + iconType: 'event_definition', + href: urls.schemaManagement(), + flag: FEATURE_FLAGS.SCHEMA_MANAGEMENT, + }, { path: 'Annotations', category: 'Metadata', diff --git a/frontend/src/layout/scenes/components/SceneSection.tsx b/frontend/src/layout/scenes/components/SceneSection.tsx index 4da58586fe..f8b231426d 100644 --- a/frontend/src/layout/scenes/components/SceneSection.tsx +++ b/frontend/src/layout/scenes/components/SceneSection.tsx @@ -48,7 +48,7 @@ export function SceneSection({ {description &&

{description}

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

{description}

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

Properties

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

Events

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

Properties

+
+ {propertyGroup.properties && propertyGroup.properties.length > 0 ? ( + <> +
+
Property
+
Type
+
Required
+
Description
+
+ {propertyGroup.properties.map((property) => ( + + ))} + + ) : ( +
No properties defined
+ )} +
+
+
+ ), + rowExpandable: (propertyGroup) => + (propertyGroup.events && propertyGroup.events.length > 0) || + (propertyGroup.properties && propertyGroup.properties.length > 0), + }} + emptyState="No property groups yet. Create one to get started!" + /> +
+ + +
+ ) +} diff --git a/frontend/src/scenes/data-management/schema/SelectPropertyGroupModal.tsx b/frontend/src/scenes/data-management/schema/SelectPropertyGroupModal.tsx new file mode 100644 index 0000000000..9cbabedbf5 --- /dev/null +++ b/frontend/src/scenes/data-management/schema/SelectPropertyGroupModal.tsx @@ -0,0 +1,169 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { IconPlusSmall } from '@posthog/icons' +import { LemonButton, LemonInput, LemonModal, LemonTag } from '@posthog/lemon-ui' + +import { LemonTable, LemonTableColumns } from 'lib/lemon-ui/LemonTable' + +import { PropertyGroupModal } from './PropertyGroupModal' +import { SchemaPropertyGroup, SchemaPropertyGroupProperty, schemaManagementLogic } from './schemaManagementLogic' + +function PropertyRow({ property }: { property: SchemaPropertyGroupProperty }): JSX.Element { + return ( +
+
+ {property.name} +
+
+ {property.property_type} +
+
+ {property.is_required ? ( + Required + ) : ( + Optional + )} +
+
{property.description || '—'}
+
+ ) +} + +interface SelectPropertyGroupModalProps { + isOpen: boolean + onClose: () => void + onSelect: (propertyGroupId: string) => void + selectedPropertyGroupIds?: Set + onPropertyGroupCreated?: () => void +} + +export function SelectPropertyGroupModal({ + isOpen, + onClose, + onSelect, + selectedPropertyGroupIds = new Set(), + onPropertyGroupCreated, +}: SelectPropertyGroupModalProps): JSX.Element { + const [searchTerm, setSearchTerm] = useState('') + const logic = schemaManagementLogic({ key: 'select-property-group-modal' }) + const { propertyGroups } = useValues(logic) + const { setPropertyGroupModalOpen, loadPropertyGroups } = useActions(logic) + + const filteredPropertyGroups = propertyGroups.filter( + (group) => + !selectedPropertyGroupIds.has(group.id) && + (searchTerm === '' || group.name.toLowerCase().includes(searchTerm.toLowerCase())) + ) + + const handleCreateNewGroup = (): void => { + setPropertyGroupModalOpen(true) + } + + const handleAfterPropertyGroupSave = async (): Promise => { + await loadPropertyGroups() + onPropertyGroupCreated?.() + } + + const columns: LemonTableColumns = [ + { + key: 'expander', + width: 0, + }, + { + title: 'Name', + key: 'name', + dataIndex: 'name', + render: (name) => {name}, + }, + { + title: 'Description', + key: 'description', + dataIndex: 'description', + render: (description) => {description || '—'}, + }, + { + title: 'Properties', + key: 'property_count', + width: 120, + render: (_, propertyGroup) => ( + + {propertyGroup.properties?.length || 0}{' '} + {propertyGroup.properties?.length === 1 ? 'property' : 'properties'} + + ), + }, + { + key: 'actions', + width: 100, + render: (_, propertyGroup) => ( + } + onClick={() => { + onSelect(propertyGroup.id) + onClose() + }} + > + Add + + ), + }, + ] + + return ( + <> + +
+
+ + } onClick={handleCreateNewGroup}> + New Property Group + +
+ + ( +
+ {propertyGroup.properties && propertyGroup.properties.length > 0 ? ( + <> +
+
Property
+
Type
+
Required
+
Description
+
+ {propertyGroup.properties.map((property) => ( + + ))} + + ) : ( +
No properties defined
+ )} +
+ ), + rowExpandable: () => true, + }} + emptyState={ + searchTerm + ? 'No property groups match your search' + : 'No property groups available. Create one in Schema Management first.' + } + /> +
+
+ + + ) +} diff --git a/frontend/src/scenes/data-management/schema/schemaManagementLogic.ts b/frontend/src/scenes/data-management/schema/schemaManagementLogic.ts new file mode 100644 index 0000000000..4e72b31e5a --- /dev/null +++ b/frontend/src/scenes/data-management/schema/schemaManagementLogic.ts @@ -0,0 +1,282 @@ +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { forms } from 'kea-forms' +import { loaders } from 'kea-loaders' + +import api from 'lib/api' +import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { teamLogic } from 'scenes/teamLogic' + +import type { schemaManagementLogicType } from './schemaManagementLogicType' + +export type PropertyType = 'String' | 'Numeric' | 'Boolean' | 'DateTime' | 'Duration' + +function getErrorMessage(error: any, defaultMessage: string): string { + // Handle field-specific errors from DRF serializer + if (error.name) { + return error.name + } + if (error.properties) { + return error.properties + } + + // Handle detail string errors + if (error.detail) { + const detail = error.detail + + // Handle duplicate property name constraint + if (typeof detail === 'string' && detail.includes('unique_property_group_property_name')) { + const nameMatch = detail.match(/\(property_group_id, name\)=\([^,]+, ([^)]+)\)/) + if (nameMatch) { + return `A property named "${nameMatch[1]}" already exists in this group` + } + return 'A property with this name already exists in this group' + } + + // Handle duplicate property group name constraint + if (typeof detail === 'string' && detail.includes('unique_property_group_name')) { + const nameMatch = detail.match(/\(team_id, name\)=\([^,]+, ([^)]+)\)/) + if (nameMatch) { + return `A property group named "${nameMatch[1]}" already exists` + } + return 'A property group with this name already exists' + } + + // Return the detail if it's a user-friendly string + if (typeof detail === 'string' && !detail.includes('IntegrityError') && !detail.includes('Key (')) { + return detail + } + } + + if (error.message && !error.message.includes('IntegrityError')) { + return error.message + } + + return defaultMessage +} + +export interface SchemaPropertyGroupProperty { + id: string + name: string + property_type: PropertyType + is_required: boolean + description: string +} + +export interface EventDefinitionBasic { + id: string + name: string +} + +export interface SchemaPropertyGroup { + id: string + name: string + description: string + properties: SchemaPropertyGroupProperty[] + events: EventDefinitionBasic[] + created_at: string + updated_at: string +} + +export interface SchemaManagementLogicProps { + key?: string +} + +export interface PropertyGroupFormType { + name: string + description: string + properties: SchemaPropertyGroupProperty[] +} + +export const schemaManagementLogic = kea([ + path(['scenes', 'data-management', 'schema', 'schemaManagementLogic']), + props({} as SchemaManagementLogicProps), + key((props) => props.key || 'default'), + connect({ + values: [teamLogic, ['currentTeamId']], + }), + actions({ + setSearchTerm: (searchTerm: string) => ({ searchTerm }), + setPropertyGroupModalOpen: (open: boolean) => ({ open }), + setEditingPropertyGroup: (propertyGroup: SchemaPropertyGroup | null) => ({ propertyGroup }), + deletePropertyGroup: (id: string) => ({ id }), + addPropertyToForm: true, + updatePropertyInForm: (index: number, updates: Partial) => ({ index, updates }), + removePropertyFromForm: (index: number) => ({ index }), + }), + loaders(({ values }) => ({ + propertyGroups: [ + [] as SchemaPropertyGroup[], + { + loadPropertyGroups: async () => { + const response = await api.get(`api/projects/@current/schema_property_groups/`) + return response.results || response || [] + }, + createPropertyGroup: async (data: Partial) => { + try { + const response = await api.create(`api/projects/@current/schema_property_groups/`, data) + lemonToast.success('Property group created') + return [response, ...values.propertyGroups] + } catch (error: any) { + const errorMessage = getErrorMessage(error, 'Failed to create property group') + lemonToast.error(errorMessage) + throw new Error(errorMessage) + } + }, + updatePropertyGroup: async ({ id, data }: { id: string; data: Partial }) => { + try { + const response = await api.update(`api/projects/@current/schema_property_groups/${id}/`, data) + lemonToast.success('Property group updated') + return values.propertyGroups.map((pg) => (pg.id === id ? response : pg)) + } catch (error: any) { + const errorMessage = getErrorMessage(error, 'Failed to update property group') + lemonToast.error(errorMessage) + throw new Error(errorMessage) + } + }, + }, + ], + })), + forms(({ actions, values }) => ({ + propertyGroupForm: { + options: { showErrorsOnTouch: true }, + defaults: { name: '', description: '', properties: [] } as PropertyGroupFormType, + errors: ({ name }) => ({ + name: !name?.trim() ? 'Property group name is required' : undefined, + }), + submit: async (formValues) => { + // Check for validation errors + const validationError = values.propertyGroupFormValidationError + if (validationError) { + lemonToast.error(validationError) + throw new Error(validationError) + } + + const data = { + name: formValues.name, + description: formValues.description, + properties: formValues.properties.map((p) => ({ ...p, name: p.name.trim() })), + } + + try { + if (values.editingPropertyGroup) { + await actions.updatePropertyGroup({ id: values.editingPropertyGroup.id, data }) + } else { + await actions.createPropertyGroup(data) + } + actions.setPropertyGroupModalOpen(false) + } catch (error) { + // Error is already handled by the loaders + throw error + } + }, + }, + })), + reducers({ + searchTerm: [ + '', + { + setSearchTerm: (_, { searchTerm }) => searchTerm, + }, + ], + propertyGroupModalOpen: [ + false, + { + setPropertyGroupModalOpen: (_, { open }) => open, + }, + ], + editingPropertyGroup: [ + null as SchemaPropertyGroup | null, + { + setEditingPropertyGroup: (_, { propertyGroup }) => propertyGroup, + setPropertyGroupModalOpen: (state, { open }) => (open ? state : null), + }, + ], + propertyGroupForm: [ + { name: '', description: '', properties: [] } as PropertyGroupFormType, + { + addPropertyToForm: (state) => ({ + ...state, + properties: [ + ...state.properties, + { + id: `new-${Date.now()}`, + name: '', + property_type: 'String' as PropertyType, + is_required: false, + description: '', + }, + ], + }), + updatePropertyInForm: (state, { index, updates }) => ({ + ...state, + properties: state.properties.map((prop, i) => (i === index ? { ...prop, ...updates } : prop)), + }), + removePropertyFromForm: (state, { index }) => ({ + ...state, + properties: state.properties.filter((_, i) => i !== index), + }), + setEditingPropertyGroup: (_, { propertyGroup }) => ({ + name: propertyGroup?.name || '', + description: propertyGroup?.description || '', + properties: propertyGroup?.properties || [], + }), + setPropertyGroupModalOpen: (state, { open }) => + open ? state : { name: '', description: '', properties: [] }, + }, + ], + }), + selectors({ + filteredPropertyGroups: [ + (s) => [s.propertyGroups, s.searchTerm], + (propertyGroups, searchTerm): SchemaPropertyGroup[] => { + if (!searchTerm) { + return propertyGroups + } + const lowerSearchTerm = searchTerm.toLowerCase() + return propertyGroups.filter( + (pg) => + pg.name.toLowerCase().includes(lowerSearchTerm) || + pg.description.toLowerCase().includes(lowerSearchTerm) || + pg.properties.some((prop) => prop.name.toLowerCase().includes(lowerSearchTerm)) + ) + }, + ], + propertyGroupFormValidationError: [ + (s) => [s.propertyGroupForm], + (form): string | null => { + if (form.properties.length === 0) { + return null + } + + const emptyProperties = form.properties.filter((prop) => !prop.name || !prop.name.trim()) + if (emptyProperties.length > 0) { + return 'All properties must have a name' + } + + const invalidProperties = form.properties.filter( + (prop) => prop.name && prop.name.trim() && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(prop.name.trim()) + ) + if (invalidProperties.length > 0) { + return 'Property names must start with a letter or underscore and contain only letters, numbers, and underscores' + } + + return null + }, + ], + }), + listeners(({ actions }) => ({ + deletePropertyGroup: async ({ id }) => { + try { + await api.delete(`api/projects/@current/schema_property_groups/${id}/`) + actions.loadPropertyGroups() + lemonToast.success('Property group deleted') + } catch (error: any) { + const errorMessage = getErrorMessage(error, 'Failed to delete property group') + lemonToast.error(errorMessage) + } + }, + })), + afterMount(({ actions }) => { + actions.loadPropertyGroups() + }), +]) diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 1a5140f7f1..b0e641bb11 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -583,6 +583,7 @@ export const routes: Record = { [urls.propertyDefinitions()]: [Scene.DataManagement, 'propertyDefinitions'], [urls.propertyDefinition(':id')]: [Scene.PropertyDefinition, 'propertyDefinition'], [urls.propertyDefinitionEdit(':id')]: [Scene.PropertyDefinitionEdit, 'propertyDefinitionEdit'], + [urls.schemaManagement()]: [Scene.DataManagement, 'schemaManagement'], [urls.dataManagementHistory()]: [Scene.DataManagement, 'dataManagementHistory'], [urls.database()]: [Scene.DataManagement, 'database'], [urls.activity(ActivityTab.ExploreEvents)]: [Scene.ExploreEvents, 'exploreEvents'], diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 0435a27959..aaa93f8432 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -38,6 +38,7 @@ export const urls = { propertyDefinitions: (type?: string): string => combineUrl('/data-management/properties', type ? { type } : {}).url, propertyDefinition: (id: string | number): string => `/data-management/properties/${id}`, propertyDefinitionEdit: (id: string | number): string => `/data-management/properties/${id}/edit`, + schemaManagement: (): string => '/data-management/schema', dataManagementHistory: (): string => '/data-management/history', database: (): string => '/data-management/database', dataWarehouseManagedViewsets: (): string => '/data-management/managed-viewsets', diff --git a/playwright/__snapshots__/pageview-trends-insight.png b/playwright/__snapshots__/pageview-trends-insight.png index 72c1b0afdda5f8691e03224a5be5bb1a58d6fb5c..7ade46cb4fa0a047b0dfd730b75888a8fcea524e 100644 GIT binary patch literal 35485 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 literal 35919 zcmb@uWmuF^*C;%Kijof9C?VY-4GKedgEUAtNSB~=Nq0+kGqfOG(hW*?=TPS!e4q1t zKfdcaKhC-TFmun|d#%0p>a{k(3UcCT$b`rs5C~0D0-^*0ApDA1ZF^sIxGSre_q!*+8#QG6oy?yKT zAkE-u4}sYG4S$0_gQ?eELSBNj?3<~(sqC8<$tk<7wt*@A@5srsk1~)a-IpNOHKZC{0t#ShrecFYvylp z>>-=xL^QRj9!BTXp!wVA5<*vAWefVh5-izg~^NY0kW&lalsYl@W zk`WPdi;Ft^b)AnxDwKP>p1?oN7$`YO85tQ_T3V{BPapLlwtt9-h|ty5HLOhBe{U5W z{*Eucar&J*O#fXCrv3-B_a(mD7_}mI>nKkyh0Okt=y{#TiRBRRIhQEVe0FiEH%kRA zxa>|b`U~6N!JJ4V@h~wvoVn|MilvZ9FIo}_c(D+Dd|C0DQ9nkDv(8 z*2-$ajH4k3gbOAnURYdYjOm*}|5aq4Gs}9T>$v)F3JoyjjWVf)2-MZma(AZEDDVvn zh>z2GPXnqw?2N4z&OQ|3cA3nck6|K0|HMQD(J(Dca3a9rxU7sR>y@+vf<{V13FEM6kD9~Wn4Bp~D>2x<>VObYdZZRcZA%kkU&`7-Llq}^~dE-#eEWt;AX1h9Jd`apu7 z=W*A={o%v#(9rqW*~Fda&P1UyfGwBp5|M3lrD0dd-Oc603SOH&rxK__Rp|aIr@H#| z>Ueb){Z|A&VUzh6Z6+6I=llAX+B!Nu_t&N|&P4@`3pHlWt`+6wK39W3!bIYRHlF2A zLrKOxzp~RYHme8h{pQzWW?udw?C&<8G0!?Q;Ia|nnAYO0)?}2Ia9A}ahnRwv|F=Oq zE#XH>w8`_E8`yLClzK_`zHg3}Od~oRd<)Cd(>`;}VIs9&W#uLOE~A5XK6(#FNukcM z{k*dCMW(B7me}-ltPf=L9#q$@0$GLU=I6O=7P!4&XG1AmyO{R5N`)$PNkSg(?j|NC zVq#)8OU>68`}5rwrvBCuqO?*8{zxOkrN(^dAT1?j@>% zW3_$janHzF1g4K{wV2n3y(v8mf7aG-CKKPXD8wH03Ko;EV3;!(p8ei)W<&+hr3<(ieK7%DU^af2yWJx>WrR>i;kr zh0O|9G+obI6Ak3eNwCr)po!Vo0A@8`Dn>xeT#HHLp~e^Diiz0X+#4~Gx?A-Hwx?Nc z<5GB@y0LycL4O^N{`>@_b+TCf^8T+cA`&q%@l=Us&Q0IWWN`w6=E*_RA;3z%w*+|> zH(qr4`mTlZP;o;PwGz|38fqHra>w~t*i0m0Fe*&PkHn(eRY{iR3~q-xXTJ@C#sWCG zxNPU^WOGLOZ;v`R4mzKw^X>K$-LSCZO`CBT7#Mh5?6Jazy&oL5M%|b^ti8Sd^j``t zP=PVQx#fD%Dg4Zi`+1PQdB;*%9;TwHwata(U%xKjF(Z+Mm?B;e>-*HmF=|a3wq5cV zQaW|WA?#|ON)Qr@Kc{pfai4WEZG?Hg`DC&uLgz@=F8j64ic~B7 z_zlt`JZ2uJPtz*u*}o)`?~l zJ4nPkINT-UcT}_~XTx7qyZ_4`<8G_kI5_RH6wyI)+HvO5fL7@2l4DYNVhid~A@zD5O_ zwV06WJGaf*7*kbWM_d_WT}L#X@7(@H()2jb>&FOp7`58G?+zB5fG4TV*~P`w*f{A( zS^%F-bU#bAy7SJ<>#M1$gs~TWyp#(=vH;`E^0biWMb*NI$&TB5lS8tjC#*w_PPzxSa7k*vje@-et z+*=qE82~g73>J8~bl!dmOYaVx{dzITYl*_XA&YWh+&|?W*Fz9o{nKrITK!}3KH-YYthtWR2_fwuWUvaZD>vn?JjUS2Kr^_RnG zd?X|!LyNM9n{p2#Ke+t2r1egPAw_c?k6XO!G%cswbIX)9BXDkJQCKod{_9Z?m>gO> zxudS@w!T~5_BI=CLPSMHA!p~)dWQ>8|D#3jG=N2?P%o+Tc;mV$#rJdP(R`Ehbot!kQH%T0sEa^sf`5LK|W(&dPijBDe>YT z=BM#EOV+}wAt!r#u*7qoo2e1|(m@&BvO6vb#m0AT*IDtsXI|nLrIA@DwS5F*O>RGm zV87F0z2`T`V^o)(QEIWKo9Ssa4qn-R%Qw5#;fEQq{td;^#N;2bJL!@QW+-uhdM-73WR5=Hdq zY+tolM^l^!P+iX_)ko6!F8k@r7od)tz)_SppY$i_c_+oi86VucZ#Ppea@Zyd#U!ZU zGMye4h7=FJ$cVQ$d&o}u**g9%jUro46E1~clMW?Yir4AcJ30@it_RSEPE9RVfN%NM z(NgRt7)OgC=A9C&%o1*jt7l$HC}^6XUcEogRFP$R(H-mx7xs zNoz9gaoCc8l@nd|FhF{=vbL;^J{Lj-E)e1gTuY@Vv6JgE)0v#X=Rr@QvIA zU0&Ct%+@=zv}MwD+K(T{9(q)XI{ELEk`9@dV&6Nb_);H!|9H9YUcGPS{e&KymCuF6=u-fzdoIg&Ltno zoERs831_@PmKIpd8?7NpW5yg3ntS)ViB}3Ygx|@e+5R})Mde)Eua4ca1LA!e`Obo4 zvwq|Vn~`yw#Z^gGd@~esj6~$&cdYes%%p?Bzm%u5*}=ppF@JdsbK{I@qbmf063Q2_ z?_i#D27gdP7QIc{i#anRPHlhPm^0U!w2usKPo!U++2*od#}hd2&(*H2t%VbDm1)%< z6y~QlI`1b36A`+Wa3t4_TXx)w0bsZs%AU z-tm7;G;JxZ+%dCA%%`~zXII@Ma>`&@+{`Ma89I%sL;>Pbhh6|Ve{YN2GJiG)(tvVJTZy=yZ5|T;hcRSq}`Z^Q( zTdSAaU--jBKK*Kd@CV_)_~b=x3dNYrQD?CnA)7V>qsoqYdW)NBf~>4rwoWK>PYvY&eTg{n7eZtK)jBGApCH1&U*Jj46GD zXRMJhbFNIIg`)HRkMqNprBjnugS|@tGEeodT1nmmVc=j05SMKXFt_df z`BUL?s5>C@FrnS3N2h&aAZJJe4o+ArDNC)p?O#l|W-WH1V$0b}&V?$q9;B_LP8+jt zu1ih#FtCnI3z;96;pM?zjsEzh(?pkF%Buop6JMEQ7>aW*zyGA3of!0MMW=jvSVqv< zIf1e}E?N3A0eM`gUosT}S)sa_QWs|^nyz-7-kRi*rW7W}Je;F1_NILJVRe96X(=NW zMz^{K)rl^IRGq}sW}#dpT{r!Op(LoR#7gM!y$;5WDGSu0`5|R~@OS59Qb}uFWBQdc zZ*Y+321C$NO@&`tw$!o{0T}gZulNLu%O_ z<=MNd)yVq#`pu1vqRE}LwND#?KrnQ0a6rmslWKlLYLzk`+8PgCkl?d2ykVEboONuF zjEY*KGcOI3D7n<#we|EgRN8PY?@uN1R>H(0VN8nQ7Psm*znDu=*c&n}Iz?g~oFo+F zACm|WE9dEx&HW1e>D$->5;)LipZYEk0#pWHcjEDI;w? zi^JKF)*`QOy}Uq)2{N;xY%~5imwxL=+f?JJ3cz7!6lkNVZrdK-0nvNIejkyPR@ zWZt&|>7yj?^N6D7YeAa+0hrgetCdi|vd&f-A%U&u>-NWTr2$&RyG@%Y05Q1B;gZ+R z1TA?;XsB|bis^7FkIy|nSm5FIbUa_a{_F{odr;VqR|2S1-kyiQ-|yRa{W6kBK}Bse z#&0%ohdXtE-HDEl-X717B;mUNqRo0Bfuf-?lXKnCrpE2;XSOk#QD1DIpPp{G-9tO&6@fIrY!WY7{usmj0~8NXnVn6qv#HXZb>!C%PD8h#35@U%enCB!sQ6Kw=XUdsmMI zZ;wQDm~{2@4%gS69_}tv8ZST8@wA}s?ge^@xh>p<-g}g0$gj+2$Y?5k^gh{}JrO9r z_!MY!XZOh@(%>$p6D-EJ*ZVtxOJwj$Fz>K9tq(#zss%95m^8EXVqZfi;i1qssPglHU)UWAr)=$=mU097 z5r16^0zWb@EiI)dB=mR|MoR(d4bA)mLMS(Dg=iy1wbHcmtY%7vrpBQ_U?DYnSn98Y zG{(bigqXMkjoQKrm%hS-c-F&`y}~O?K`q^4OnKxzH$hd#$#sF#?RV<&dP;8c$k=RJ zv>&lMBvDy)pbF(Ia}V}~%_ku{{Wk*?Z{-1da8iD6rap9-!)atA%p4u36x91LSoPNW4#P5s<6LvoIDw0E^2vy|>HfWF z=!sUg%bl@a#^_1es3ZCHId!DD%e1D4%t!ew);U3WFD*@2JhZ7v>ncN|^=BKpYK9~i ztj5u3qxrs1Cu4d7o8h+r#|Fvn!GTT=-eA_r<%m;%e{O9Q%0{A2J{h+$rk{IQ{)!SB5Hsgxz}a{BIPC$>KPtmWYaoCqu~uDn)lmq0*R%UKX1 zsEd|fyvCb!mb4t}{yH}io+rm;W~#L+A8ldg=x9xV({O~2hF!#zT6*AEee-!{`{BgI zO)%Kj=j1q8hn~Wpaos~rK#=iGy>o@QQKLE$5mC8r3ol#n)&6|_!%?7+C%_>5q_(?L zr84Al5CmWS``c@Z#GsT^VSL$J7~$sumT&SwU1U317ojLm{Z@M-QT&C;=W5JjphmVj z`DH#{#>NibCxdgpe&L%mW+NvZ_4(-xehXNMope0O7}9MG%ShiE_hb{g(KubWbOwX3 zgIXk=E)*`_NXeuU0TJqEwrZ@PcnNn(hF7#nORGcCfrO{%_T2gsJyuu9$?YC;$o+Dc za)<*&Lk|M^zVL@8$hvNl>PT7+mUC*dD&TcykETRrHyu)x0=W8k5 zw-}-FD`ll))#lVmmBeClQur}6f6M!Yeqg>Wo6cJ?NgowpaD!@77zy*8o+eP!t1$&( z(=kb$VoZ9B%vw~WD81Eb-NGW(c)S@$Xb?_S{+|p5d3)` zvg6?OCaS2e>SHB@@7Z?l&Q5&#mtIab);!1k8*1vQDKRE)!~0rX>awueYW|y9;~Mue z4aM{f=H2VE6*}Bd)phDq6cMbhwA#R`+!pZ<*MqWARv+Wi$}!*OiATOy-(t~hurn|> z-(G6*K*N|H>Fa}f-@({N_}P0GF^Yz^v&xNelk1?Yx3GovVqW#`7sg`(*LcmFx5nto zqPY*cyQ50!NzIbD)lSp>3hJ|x=B51}Ha}+0#-C0>#hBz!`JK(gH~e~a8cFf;I?JZp z-kh(S8gIw^n%Eqe*GWS>&wDN=x01oHMN6^KGI&{u{dKtq;QAutj^D9(l(ftWK$;P3 znL7qM`~7oRRj{LtotBQSsHh02wm1$yu<*b|Q(cDS{@9d_WPF^NlTWxNNUU^F7kPtn`vu2wb z@yQDs#hLEt=g4W-nS(t2ePKqM(`)OiabkJ8iF z>)5Bl^FSz@!>0;qG`*ExW-UiuoMAFy=(#2MKpsUyV{zZTlAcB|FJ)?0KPVH>V-wGS zb{e{<;m}%H2@f6)eWSg^KM@i(%_>%L^fEi&Y=_E*t9Dtg003NiY@Yjf*)j+&&$u4T zEOoH7HGQ3xD-S*i)lt@Vs;oS|-R4^P>W6!Qn%rc%XYzfiGUbB-5NLch;_7tAh=xZQ z3^X*o<(r+x8|Of1RfL|`9dI2C`A}&YBHsq4-yvw4wZ8{~Ll*FH4y#>9l z>mefBEd#f6%yA!e20v^oXTIJxVPO5Tb#87BNW@L`kb3L^{xZj31rbZf&u8_6?(euN z?`SpYFuQD+d6EfKb9*=l69bSzZDmz5aBna#7pn*p-U3Dw|Km>k0j}f1G zc)mRrzNk&OvCT!nS>Ett?N9gBYx%Zoq{E0s;w|>yt}MEg$Xbc&4q|*i*lC=8Q*;Cd z`^w8nOAiE~5iwkj7R`}P9%g86NFH!GD~~*;>MZ|ky%BQ<7L0z-?s5BYa0(8}Q=Wu= zOmyM3ZOs52nrp*qJF)K>z079ax8YRbElj%O+v&^-(irj0qn4y`6`Wm&KJ{Zjyvdfg zp?VHW$Xx}Z>nj?QlxBAE1axi;DIof7^A{f>*5;*;Z=0(`%OEZ`=Eid=X!CsY=N<`ZkL>|S zKcU9H;^gE6BJT$aOH20O+XsLDvH}Gdj|5tTtzD&q!P#|b!H3^79IuJ>uPS$>oz0eH z%(HWuq>aU{=;kgnh@Gb(#fz+rnsv1s`%tkE$a6<8nNdwGrqb9AQz=z?A(-cUZ<(ug zH#D)T4@mH$lk#63_mEwtYXXJCyUHF?t!CHCzVUnw=$LWqKzYeSVko^Iq3L#hJL$|h zk{A*k=RZ>%o|kdg=PTqm(`(=lo|ZU&x{1U#$OgiUQ{2Vn!#vuLk*%Dmi_cq?aZDxL>uyit-DT12IXwV8;r z-15*6BR~H_v&f7e)K;&l+ZlC_d&w6((- zS#Phe@nG1WIm}B28yX7(1w^8Wf%2n z453^7?E=u=C{R}7TwIis6G@xMm>q=Kh~vLuz|YtcUp|vtZCph z7j<+db#%hR!%0a=ftZp;7y-Wo&@Ri3PSQ*Xd^|kRrbcHcmuiwI)#_~%)2++7JbALg z05t>#1hk-Y{0R|j8ay&S=e;2!F5<=}64z!RZU0sV^#`xLK zDliDczvkp1Ms99z_c_z3g`*4&zdGQ=lY7J>;5k~*B!&#&J+j3b#G^{NEYSytOlfL%H?kW4XIs2`G05e-M}mm|C2O8d#q$ z*K2dy#r2+j!lEH3CkK#YwL4ryZ#r|vu-fRShjr+`vUj}y^KVXhLKA4qUdBpHk39#V z_{pP1bukN-$9@6*#MgL8@K5?k4)%|4od3@+xLK8nsz{#0hkseLCnqmCM}&W7>8O~( zfPVm4eh++wAG^GoHDCE5LEuX8rFR0w33Iv(nTQ2)g-s>tkafrmlE3hfx-UPm}cZk zyrFit8l$mHm+Fu@)ii(?l{Vn;Ktd>Ui&IWeWDW(siB1jjoRZm;#f zEisX4Xf)p>!lvG$EqBY5HqWBdWH6Tzm2FthZ_LLtKw*y#ohBLP{qVAJf{&6?3bWG1 z&3!odn;+%z3;$lGTDm4D_r6RM!5DcDDX9w68>e zk7PO**jokKJ_?JJhaLK)&P=RuZdz`)M zTM4U4&%r7*Huk7SSLf*MqbH|glv=*#R6Q8t(blB4R6}_ytu)3K4Z~ShF&SPuiJXT# zXdcb9KDghHJfk)ALQ;({m1aQg^)M9$f%^u=GWyiYxVy2vdyx~E%cZ!}Q+_w`=`qp| z^zmxX{w{G&!jFos<9Q%Icmit{qv91Le&l5-jdONw*SG!Mp8X!g5i(ac zazDE`B^I$tEWDB?{MMpra^CVVm3PrgN74wvRXM}%#J;*0=#2t>Az_fc$cJ?HRFj|w@4seg2(v|yX<;4kyG>@u-%$W(^rNIt za-;~%xTJYB%*Tfpy6Z5tU#iG{sH$d?dsvF1QQ7n-tr;ykpwl0%9%;_F(|0H-j_IWq zN(5e8HD~s=-G*;E$=P=+#!-@**P>Q`GW}>IC6r}lvA|J{WmSLQ_0d_bK_Dl|bV(YR zll5hJ2knh5Xm8BjdW85}`TBBc9Bt#~jtC~#eweDcZ(LM$`cd5VmCG@Ae1UQ)u~_GUVYg8PvW9Zn@b9OcBT_h##89@ z@2SiAwg1+-ns%4Gs+7QHqc;*smF{|Jf^-s+E%l)zAWRbuirk=cCCJwWzk*!;V+H#g z$l5wph=k;e@6(fwmPvI~M*ahNRJViu@g z(@E`79U)83xZPd_(BOSFRdH(=q%z+PXn3)qbhwa={oW#{{)@1Zn#3;z2e2eA^;3?? z$v!mQ)F(M?>mhyg$n@ILH3kq$YYk#yf$Nsa7ar!^-;*1Smox5TTfDC~syu8UK7{Ko zI-H!zmCD3d5E8sbV}zx!W$uX6wU=*s#kx+3+eWpG4DH@JkUNl5@6h79I3!|jZgD2} zxHz*^{7ri&n_v93Txet{oa^9C{x^<%J#I@bj1b2&f)T7MUO`Ix1~1id8wNGtOh5vt zQw0sKFC_|9*f>F#-#L4qzM*~6*oFiVeIYl&seqt-{)hb4*(V1krnQx^U1pj7eS%k+ z^gJ#vgJ03h_pV_ZTWHpm$W7g!`)-<${k~hf*atzQdqY1tv3=+@)X`^p1Aa1G^~5DR zep)rPpve3?XWu8Z<)sT`)`v{WkPu89waJ22NSu>jQ0Ys*MkiG#?A)>vHb!DtDD=CF*>#k7VXs+Wdr-Y8FXbp>4HUfaFN{U|%}<6#9AKaN(t zL=WHZdjV(@YmVN8yF+)UpRUY|{p!NeSoNggn{U9Dg@wapqnyi#9*-dGRiuRDYLS?i zsxoY6@z6oXg_xHwI2CE;8I-r-OeG6{<~QvZyw_X2)t7ZHUpl{mmcI3&%L$}J?$Ap8 zZQ|u#%6j6n4eyK8NB9bDpn3h?t|3n@X~Oh5*lj47%?;Aez*ZXlp1JnpvYyO*s+g$h zTCV6>nepYx&UqDUe^JSLiY3JUWi=((-Y{zKy~XHvbi&ucm{w&aQNgA|DS9y1+rGR9>ZF4E2(@nN-~xbS8W3E(Q%MkVV6{hk>;KJJ~REl638 zEN|HoS{ezNcRJpY&SmX$+0|`6J+%`@;?y zw&2J@DAB?6yA}tyZd@vpG>F0o&zHtQb3_L=B_)LxuY$UCArK}6yjrC~fgg>MQ{~zp zZv40*B{tD91%aVeh54wO#N=vIs*8&*I+I_{SI}@&cQgr_*p)C%B*_t;?mR)EhOyZx zkRwMy6l66GRFx%EUMPN{glIZu7f6x3fz~(a%uf7A3uK^%*`TPZEV;{KBqx#Z64Q)4ahj0%@3;c?vDMX^Yi>ki zF!i3T7ZCoxuIcIps}r=zhy9J%5D2=_Tkg-b=Dji0`uC36&R(3`Uib?YIa*6n9XBLj zjnceZ7Dg{O4+4q_m7U@Jz4~kvr_KHhORvPKr*chjR3|1TCsnYGbIbTXyfl|IBrn&b zt)37~(lmCedaJ3Usiqd?uGY^mzIpE)ufDdXGQr8&R}{i_sTLd^o%mFS82nx}5JROu zIeDkw0bOM$n!-0BA+)3_J1*T!&Yy)xbgRgG25@kP@rY4S)tZY?s zc1fob;uKvF0;E5=Dx?u;qzdRq)bwXtt$qF+T}p&{3-u_6q`J_ zfx0|a{Znq93GO6k-+EPYRTVGhv!JllQHJwihK=0Ds*<53QKvP-DMl7M`{!Z(vGi|~ zTrs>4BsjnNo@!DqVctE!`eDsTA^c$_SH?-!j~g!e|)$5fbQM)j^{7TtMusfZ3&!a98qiv5?*2M1D*Za9$=mk{FC>E;<32i!G=G8Uz9gyxjOInaCXaAWG?$THG1#OeUP%P+Z!L6D`6GG#d&Ck4=3z$ zhCFTvSAYL*MbGGHZ990viJt6 zm1rFA*DqI*fZ+>#G1l;CyPDSXSsWd$w3@8~sx^i{=L68d{oncNp+Le5cov&n$aowe zSqgje^^yBQp!T{Jr_}Vh_sCT9@Sse?Cp;u4XEl_$l^bX=CwD*Yg7-pPoD49tGtV386eaAnh0(ly^nfStJx!%XtpkJwL`HRdu@E`?_Cvx-USsc?_f;IIYB=fb{o? zN!(hqk2`A|El0YjWfK4F?G4_}c6D|A{Q2QB`u!iKR}Gim?58ngKDYKAUV;>)w4j*) zIZmKSDkE&}?3TutXnqvfp;z?1}(bw+-~!);kxn?yk6e^+pRNXA&B}p1#TSw&`T{tNU{MZrVLHw`=(ny0V6Sp-;FBC|fnQ2fH1qA8UXs~Bj~I&aZ$6|G?~Ke&Xh@vp;jL&MMd+5-8Q04CFeGa1wEf*6Ej*yvSCiEFe1 zC!nD_9~t7AGA?X;bC!prKYaK$rS;nTY6Vc*O##}(pgrMYzo1?t7H9)~#yxd<5Y1tW z0SX-XN3x=roP}bIo=^t`30_fi>XD^a07$mNk!;dm0f-lNnwf7H9CV`i2nz%!-m`sS zp|68oPBBEJ*L<{m0dE)p>}(&gOPpqwgFJBh8fHu6=5>;xi)#Z1J`HC=9kZqnkTruj zV9AnHd5FqUYpxL-!$3HOezSKg5DshPXE!mo&q;)gkKvRmdqkprFvn1=h1=*Hfjs*T z|0lo>xc=}d9SX3nV6(Ds{{&GyL-QS+Mwb7u5Qq#JKEw%ILp+UascO(_CD;mz=psf+AiNdNu z^e3Qp8#o;fV(5Fx&sV~ZB(cw5)5d<@i#2wB0dO-4a3FRWb$ScJ(1tP#VhV16|AHRz zM)MjS@;1x|L{hOjRnF6}(hC8QSh$2Om>z3uPjlW+*-IY)>Shn^A6i4qOY?p`<|W^m z-i_sNGD5Wg%FzS#{Bl!sw}P7t=$37O1Q&RkuJr(MgdTzP1!jr)2c@S-d*Z>aP+%J# zD2D{VicjGyHoTLT&(5a6{@uY%o-Kr{<-G|^!+=lgK~wfcHZsNpE80H;ST6n!AVED( zCCL~2NTGY~$o#wES9o~!?&lVD6bK+KHey6BCx{X4Uoh`6D~W4H9Rpds2w=ei0p-6m zH@CB~aq$z046e~<=t7y##z%@i0RwU3N0a?2?TE^9fT7fy9^q1sj@9+`T=R;cFK9%0 zzA3yhhxuD}7KfQ_vFOup0LxQs^%}1YXaKA-9!Pgx3Etb=i-vYBp>w3&0=3c|j{D`qR^f~R z|M#>3o_^=2UoRD23E=iQzXIuZl?dQnp2HvG`_$xQ*zM`au7iEklPb8$VeCg_;EfAcCIal?@9e;1dIQdP)ou0y$^!boM_?Y72>E&j(Ec**{j>kX&2e`2H!8`i<80fh#rLM+WCD2Vh`WRl%7%TyslSpMMG&wsTx?N>du zuRC3cZ_aIaz=}$!vK3L!@1?MC)D@cgtvkH%$524-@bj6N_zc{;nb!3fT3l?&%={B2 zPHMnM4ys_amLeyQG&oRnT2+Rd;QZ5bY))g>NaH}c^xW>ZC&$^TsC6Q&v+4 zM!=P&3Y)3)(2-Q0YADnVFya=qyuicA7!LPsx@+jnt~T%vtu0;vYdJdgb}=#^>X2vS z1Y7m6;;-@t1$kS*uRT4kfu-mGf7sxxP-?h=cZrF9<4c+@WBU+Z)JA%NIGa+0^10Pl z3_9w^T_-|-O2#~f22=-r_snf_JO$yC=Sk>)0>pHydd@)Td&{hz4CGAcxUN1H^R&>i zE@`wG)Q$isoOWYmDsYqn3uey=fizZgVar#-vanx&_kOLD6*PwgZCz8(q8lG|dtmmf zfZACeCs=ctH{ijx_y6`mAd^lMpSeB0Uy_1H$E(fvj?@V>iDN>IuHJ(1zrdA6Sm6|j zF9ObUOAdICC5+djdVI|Q*{_oKV##|D|@T04j2h&=r%{P`|R(Ne}RsS@>}hGhWss_?T0pQ(AJ|CD{=aC1B@{I;NzYVM6+*Rw~Fs~ zObUSrQ2=V}4le!)wGn&#bcs@3N+I+GnsQjpv^Ko+S*TBT0Q>eo2x$Xmqzx5Nkb&Bs zW=s5XkeVG@U?IYNe!7DHy-c`gcu0G=FzIV@*hxwhA5G8gDGKZW>4Z=O#i!GmMx+x^ z&M^y!Ak-IdC7I;BxA9_Qg+`g6@fNrsYupC#gS38QcS>0`2@*(y{vRclnImv~0=7~P zx5rO^xnymw%%XQ;DzKr00^nMh$tW+z2=-PSl|8QCw3fnMVc2z-8$8;kg#Yy<7vVTt;s^!aE&=K>DxXs*(Y4 zD;=NW!A678sbdQheKpVS>m$F9Xw->RzGApU{0Dq^Y-2?j7d;%uXE0M?>^L$AQJLT( zVBPzFxJPr&-+N{YFY=cP-B*{br-c(7t8b7mO!=?}2qJ+P?;l$6+8-!{f{`gaF@e)I zq@d-~;BE|ezp;ft=r^lL0!3L@0~B)*KrpxmGi(U!YMCiee3KpM_flsy?`A+!iH5c} z(uDt)GY#(gzUUS(2oZjurhQ2O>;lEnxWZHsCY$8DLohg3V91#`{vEm_A;Omra9*sQ zo6*=34V5NKK1*Xx;|*)4(i710Zvdxphi>Ctexk*TOcsWm zJ);b;*SO8DH%o7l8(!sG58Umy*PvD0;JT}Zc?93EV zsDj#Xix{U41~ba)^Tishh66~Q!OeQ=q-Waaq*wHbK$*4GkP)=oqY}b=8}1xNL2aI| z^dTG>g!&ZDYRVo+tfKP_jSqq+__9L|Ve>>YPS09Al7nb~>%;E|R!5o9K`=N-8^f0O z{^0Z1Su&y-h)U$yJr5~wVHG6(b{#VSBf*_5JFHzqE`qse`#~#L3}d7gWhuxR`R3H+ zc3`2RIb7)eLBwi8x!cme$`gDP%Va`f17E8^wn4yf1Ekk~4X1xfNv@tJ5YPDAO6HpI zU|OE$Uj_%My{!bON;DF-2m$to_yO)6{5lX8Y7yX1Qpg*VSLERw*uKA^aV7)=RA>X` z?~s;N&l8Z^OE_vVac7}9GRE(1@8W`JCeeAj2n3CSgS^cQ;EGo8AIwjrI66uFJZmcg z_lmO1ckgwLwzg*h)RZa~FhE;h;fkeD6>toBlJ~jY^Ehu2IOzluMGw^}t4MlYbN~X9 zi2)aow|ROC1ITKQXT&1+ZJ8J7fTsb-2EwwZ`~$DNC!RY~`o4nLtN5EIATKyWa+&XT zm1gP@8`HJ4xTOYdL7@!H!Mq*+g(+F~nm$Qfl*<##m`**pa19ka8UokHpID*Xz$#xn z9#6-jQLgctUL&wj1%EL6^3+swz>9!YrscmQ(03HTU}#eniFl->A>q1M)!Y4&%?rt_ zAFE_w@STm|d{!8@?L-0Si*tuZ-hX-lNa2+ET4I_-Mq=_VZp30X;;TTQ+;=J2NI{vO z_W*5H620w#N$oB0tsrTR2n+GfvElGB5%}ELexeRtT9)Q>#sUst0bY%^Qz*vSSh*00 z>(`S40KXPWChc~nv3i9Z6ckGWKdULEf1@~M&3dnlS-^@zqDqCc=>BLop+P~qAKRUY z_l`-#2W8kmpceS9(W}z^y+2ncRJi%I;+|FmFcKSU@)FY^?Q68rafrBz@D-B~DN`O%OqrD-AC8Wt(CIVKphSFF28W>h|ObVCn1Gt~hT`)v<)8*AY=Cw4j^Z`wM z2Hp6F_PuX^aZphFUHrbnOx`}8I3RdukAr)WH}Onas8%nO^Ey;Qg!t6?3Nr+V0=?}) z_%GmW^ct#q5xU#2viIp~r43J+d1>aCGcict z7(RZQLx-9i*5z8a`kTT68LY@TE!`cN{sZ7+)dFXK3jF=DJH%5_FzAZ`JM=0AxPmcb z6j@2~3^W6R`jyyak);Jv#|Xq0Vtc+Xz&0r%?&gqA*93O#A>uhHCH zTS4_Dl5Nch+8VG0&cyXLC%N^;*Rz@CdfF<`&=@Xh3j@LXpfCj*Y~UW}&E;X+CDs40 zYsK^IIC{4PU+j*dKoe_aXh3nb$GBm*_?~HWF)6?^gOjGx(2=Z3C}=sQ}){$cVg@l!J>40MEBw=e1qiCAHA~)#|q! zmL$e6cRORRz}NDxWEw`K@H{*jLY5v9cYG?AUIeaqA_6*q(znM9J$+H(=@l4U*wo;* z{G^?KN;gMIQSrD(=$=&&{-g)3tgbTawGKKffb=J8LTx_o&d4sR&}B>lI*X;iIujk| znG7X{W$CmjnB5ucv;g>L2|!lQ8@NWXm_g>TR~a3Rsn+=fLOJ&frFDd{kb{^WVj<#NGIq5xb}@_{@>oP2hNzJuDZOm4Q4 zvNa-DapEh$R9!X7g#a>N9`&d4THQFaAY<%juRdahJ0OcF^1-UdU@>vs8=f!bg{xTQ z5+6Xt{*P~9?NU|RNhg%|9O=vivoxI8C3(yV0 z%FGd!DWJ?pAfkNvS>Y1}3qE9SVR;Xxyn*+d!2iW-A=BgNfC<5{>YczIWo^c z?r_VUYA9lP$HHB)BEO4fVU?|DgS|K6OjGwIN$S}+Ap+>pS|FbmKPcJ&q+&C{Y)4s+ zmKt)V*?^VAf7YXGFF4ly29V(WqrpHL|6h%L1z43$*Di{Rih_WMv~C1+Q;L)zAOa#H zA|c&KcZZ}XAp(MQBi$t+jUpi}-QcFXyUu#>{l5SE&wtLjj@SEMz~0ZyGqYyJz1GaE z)jKm@YeV6lxJXvqA81Zz(adP_#T+o7t17Ziwn((oC`CR>V_qA5{o8}vBY>q4M2I>e zpfN{byFXrMf>FXD211VeD2=h?*>+gu6C1PiiJ_tBoC)jKPO<@=svVa(Z?EJ7l2sJ| z(NEN=h_PmnV5^zPym)@rh0CNdS`BTX13UoA5sxV>=gReO`lYL>lBfO|N#77y$RefW z9}^h(h8s-hBP1H-%41{#3^1s&I;|GgZuW_8?+O930OnY()<`gaNW&69Q{RjXdQ>%O z3lCh*{c=G2$>)dQPWdxLP#8`;EkPv~6ZaCkPt?8^Gzy=$=HF$hS3bO}CGp*=*@B-x z;WY&oLXXhCnTP00bP3u$xnGH&yY1K9zDAw6ih#=#e&zXUc-W`IOr`IhVX7HSy{?WX z#%e!-Tk$IM^-nC#mB=gm9o#D`o;m7X#04{}(n;2x-kk+KlMy^yG-`f3KYdC^^0J za+V#9^_LhBqx!VS<#7Z3&Wb@nvBX8~7AoRa53>@s`wx&0ghx+$f?%zF(;c|WI^j=+o(|(a*&xCt~j5&X4 zvF#qeU3_WO!*yx&%wK+_9B?*Q%7%a|4)u-7`%Z6ODIJ+ahvi(93=_~kUw$v^Ce{@M z`z(DLJc3o!33;URN^Jg}Tcn^T2!!j2+InoVzn*$Gv+DWTjDt&*2-G}>K#KE=Q63zs zEZIu0Mhq;?wQ(S}KZ`lD{$GPrPxd6b2!^%6`9Zi>>Kp@LfN>x0A4VSk)%Q2o*{jrs z`?U~~HcR-m{n^A=%+UQbv5)#(tqa6H|Hc%Rt9=2NWtKb_<}o8W#0ZZ2f|hx7;sm`V z2SyfBT>Zcw*4~a10uUaNgE+OhCt~ClMFd~pE#WFCN_thY!jxw6IhMtince5%lh4`Z z7qJ$uA@g|4VY2BXv2oM?cbCm)vdBKXB$i}pNA!nUCO6O@03QBEFHie*L%4Y3Zil#{ zCU5#Z?+=q7>sQ3FPUNY*F&G)4Wo>L5%N07wydZPLFd$3FsYfzA@iWTflFpYv%bp+~ zwf*pjNeETHJ?O)GPZUN*3_N3lhsgY??Y&x>w_eN7NytU}zuVmAZH<>7`cjtwdiSKG zuNK*=QKI!(>8}rbaxRp7JF5X)@RyG&@*+#~jppx2|Hc~e&WmAB(+3Ce^lk2`UAP+= ze|Fc7pl9zC7VL**WMr(buiw9aU+melWQo|Ls^k4CWXn!gB>Tn^vCsXZstO+ejQr+f z{v!fy{tx+c_$|T81o=?`*%eyYoSKFjuQBK2JhzJXx6;Qytv9dp|`Qn|cS*ug@er4OC!_irsov7_`4_rRw>Czmr%}QDM z$R&_yPn$LQc$-!?^+wh$8f4BKHk&% z#lf9elIfq~f_We=8QU1+fv=Ew#K-H~@X~u+lC7EFH48#mj46&p!C2fg^@5v~b6>@X z7LWWVh@U?G%I3rVK-4v!1gE`v0jqEg?$^4ke8;(W@1AM-sz<*9mi&uHBj3`#iU!g7 z-l7rn(|y)?y;C#wELzU`9@C>ZO$K7Dt(jWbG#!Z-^mK-6VJvX%+c2jhnX0*^rLFZr z#)wS`icExE#{W$BXPQ~kow{?_e%whuYra?OkB3D`UK4u-TIvj%z{vjlHd} zQ9xkxnOKv-dibcZ{w=~0=GBFO4M(l}T*~sfVz(@gCTG`Ohpwki|EF^RZ?l8`@$H(B8C=)Qt3Dru-`cv>ae6`nnOR8}yEa7X6SA(#3 zKnoE~ORHwNh-cGWR=%CDVdHf<`P2ubHdI#$N>=AQT8OY$yK&aP>fc03$;=tV%&_v{ zVp+;NRqVhGjiYIoqiIN81qfO!gi(LAvZCJ`NQJeWfI{(di}l|6^(**nM)L);y^jae z-`<^TqFrphaHU#4;dJ#}#ZIbZ0=LbA9o+21r+R1ysX*Fzx1&xv*yTf?8u0OHNDvej z(s9ykEy&;zDJyXQaBh`i8=EVpyEpX=4vs_=mpSIAw+_xJb(Oi17ObUOoQ=S17c960 zWivwsdiVDG`xTU_*;!i6`CUINIH^)#G0#{%%8`_$DuL67o4a%m3%T`Yo;jFb?uzk9 z$!}=|EWLrjGGZC)P4#LR#-?qgEyIh*?cFI-L7|3cYuV#Sa2~PXRd2~K5cRENP!=v9 zyc(Koot-+w`F*^;n%>DatK!SCG~1^eGlPB>7N5s>Jq^OPc}MP)@y?@xGxGH@!7*=| zV%!f+b0-;=)-J9w`IF~1NC^UFZWmb2kW2>tITRAoVO$&iZ1Sg0yroMP-Ry(yCk$^L zkzpD6Nh;F*kWEPF^>_5KctVKjmmeM;13%MMqwLMA(`IT<^+gV}-yI|IVIW~=8tq(J zlsH_q(w_}X&Eb(-vq-0mu z&I0W~te3dVfh+IjQ=SPVIO?Vb72hnUYK152s55aPyH?-wExKnmltIfy^9~ma zA0_el)Ux|{6Sh?aVNrM?LkZr-;Bn2T1mZxVgJ#9dj3b>FyQ?JDLlRFy`X6A0xgpOy zuvAqRS}IX^EcklZrr-XN*db-vdv^2@L6a=n)%~{*c5X3IN^QYRkEHS+i?$mC0pVoH zbuL3p?`u1_e=Bg-MkF~Z35I;c_7ei))LGyP36Tj$MAzIh2yYDRws_b%)@b0?7|eKW z8`pZM?Sg}ObpWy`VV!t;X^HwDFU``RSnFph>WWjzIThkCBnU5)R&ZgFTaF3v2d7%P z@X1Ra5xPlM9v7R9Cy(Z4-5h(3Fl4hE&Y?_4Ug5?P-FxA+?-n%%RNTz{y0omyfr{5< ziP*=!QCI&$-qfcrLbW0NtESi|uSuo|qR1AnNNO&A(E%QMfvjg;7zs(he5t6XY+yJI zt}LnaCu!${kDD0R&&b^KIyW#V6srj^zY?x*nrx-_MW5Qk_f9fcCyS)z$2*e}`2APl z6&n57wH7)xvB_%<+3p}31LTC4Ns^DkWjImTW@O+Ha)i`6tR6e;aNL_1iZ$jD7AmC^ z)$=0Ad&zU*9tuVJWSI!RdJh@!bwinlOJp|!{;Ub6FZ7K|Q(j9ALsfd`K0j+lhxAid z5lN*HZ!yY*+3)%`AoQM`^YFQG(vQVRSM0wd)_t!!G=6lIAb@y&vfX^y$4EnU=#sgS z;dw4%gs{@{hTZe~UdD_y``%v7TQ)hXU58TgjNYq|gYOTgIkEuAb7hUTK3;4e@A7yC zKeQ)i>c&!KMMwN;1R3X9bnqP(A`mEdBFk~#E`dO;$qR8GbokMx9H`jvN8ur zbY3_9{!B>57(F=C=rNaSZ{CxNg@^D~b{8G`x1`Q=Zj3pi0h!YW<&33xQmuMEUtouU z{C$=7CWv;q4V_J9Iji9Vlq?ijSq$VKqJV)p#dK z&mXZOP!{4c&pQ}pg(mv$6j)-dupmz*ZAt1sXHg9IldVWHPO7)7VG9?*@Hirx&$mW(p)suU;7W|P#Dnwz3w zT{nbA<^Y);o@GgDsFrg}GMBvh93!zGWuRN|Bxrt)6RoErY2pmu-*OlY1 z`(Y2g6`A30x?uu$tncr>=F z3&O#)Q?>I#S!6Dj0>spNH(v=Qis)A9{;($;7Z&P}2!p4nPoCmn%l!Q3@UI5E%!79E zI*ac44rc8{3X(c0Hl#)HFI^)DFZs4MuEaH2&&sugV|D{pw&*7x+S+=ViI5CsSlrl( zU1je$$V?EhMA_WJ_LUXGIb+%c_n>b`KoSj}$7WWrD$Z%p50C_9& za7}23(Iy%7q;?Gp7JGX!9?AOYhVXy6mTIM|Yi{VJ>tkMx(6EotP^P?{?a4WMAFuL5 z{au=LlB&R3OwSdue}q5@J=*z+9c`V+!Fe!a`HSgF8yWFbEz?_2LfHLrB#^hGO~jAl z0RX&TpW!~Mea8Y97NbD)W+z+S0)eP|4Z-m@&O3MRaB++<1ieRibZvH>e~@iR z6MN{nb=*!V#Up-&V!>zg>?SdKHdnEnH8xetb8}Z_Q(`OOw4CQNvDz(T!u1NQZe6biOdKQ8(Aa3f8Hq1_dNiwY0y#X z_nUoaatohcY2EufkCs&X)w&JLdF5=g)hHI+8@Y%^2{)RmC78i{c~3j;DtIyj#w|&K zIBBJimP7bsXOO{78C7~cef@;*%6wpG4Hv727j^4mLibFO~e1o?~m80C9 z$1X{NF1~M%d`yIVtPcIX2PUOQ){#8L@|`M0E28F4UVpjp$@@)=*?E|76tWV=smOc& z<8R7`CA>>;Uy#RgB#*A@WKM)V7A0>jGep-xZ%-c1=l*} z(HLTW@l$4N|2#yv{bU8Vs>z7Cu0YdM{zy|~hPPujfw%=CEK4?JDdLemDH7BL?pSxl zW&OOKvgsaHi#ATzDzM<4VSclLfouSJkdv+X#i{D&;33`)1!m^gq$a*G)}KHU%ckD} z9w>YnXCNYB;BwJ==?16C4}BSym;i)ObO$Tj4QAlgms!nf+ML09#S?Q!E7`#1n#s^R ztu$@4!FjAyffv~y9eNh7SocEIV5I5)=<4gFU`Jt=kBxT36n}P@O4ypJB`z!2B%SpU z67jf3DXKe}sqyyhTyRK8c=TzKE5xLh=rV!1nC70A*6`@FxnE0~RP<*9sGUPTJC+Rg zHEz!1up5%;8j0hRZ)2F{-Zc@MAJK^TQyIsEsjD(q3s_ia_d5^-)EY)44#ll{+oS-n z^~Ke&s?jGL*LEpAbGDvMf{o{*y0K3$_({jybJzCJJ$2+(sb$A)#ZvtwpVCg1ndQq_h*^gWNu*T%>L$?nd%YF*|${qFgDld@f zwG5`-r~&@)k08TKe5t2Ow2gstgT8&!9#ed{5(=mm` z2b9N4T~j7P0(yZGA1h;e((wEl^6}VvK9-z+WbR4+AR~_BHc;7##{NlsH1AHS zB(`jV09Gdr&94I>OD{P+{hFr9Z?Oq2l_jlt*5MHo*ssUd@uw+n)gNKZ=n8|M>G**_ z_&f+^I^K~In)v=bn15qs5py12;lsbq0`zNDV?ffod=Et%nl@8GbbcB^io)%i@5xp1XngJ6GEb%GcBC zb(vY$M7!zG&p*`U$+O}`wW-`*cBVtE^?9Z93mC5?ZBlR}1}PQ0(0ylY0!?=_NTcfW z?a+hy(foB+4GD*cmfAl?<`fkdqNWpAASe-7CKmYgtiimn)DOqG_bH#lMOz=c_yE<;C_Vj+%=Y6j*TTtFi8^tJ~*snMT(Px9FX{ zCy9YU3iCB9C|W;lF(ug+@>=P^WK$wIfr;UWb-by};uSEFmr3G3fKyVQ|CIYOBnSpPkDDW66W^=Ug z;LX)Rd5>)(0{eaGmb`L^D2@Gae##X4?s}B+AdBR-ZF-BEla*o?NeYRlbnSf>yo26W z?6qMIZ;M}E&V`4Bh5bt+v0^4}J$kCZTV@&tgqXJswG49+w|GmR>i9Jucl7GEeIfBI zNTYV%l05a$mU;M1!Mpc8TVewop_RO_P<+a1>=B{BOv=?qG!Q@Vz7$$^u4@suTjnQY zEazuURf?B9d!2qYfBbYC=|?|2-JL2a)a@Fjp*(5wQ5c2V9+vVHJ)dNX526eqi0&t` zr=xVhWcq{2BnU4M5}If|MJQb#UFcvHkS&UYII4QCwtyYLG63~+EfbNZTuwEp<_K6`I(&LwXmupn#z4^-EzK|HgMMT!)yIx1CML%lxMXhN1 z4}2g^IdGbhPr|;}H;qSGG5?=;m?VK$-vkVa{*HWv$w_GhnPOPrF%clw2$8+Xgbe$itRf^(D;p?e};OIodz-?iVt>D|C zj>o3`_SFqWe24K9AQRw9jQBW4DU%#rgA9sLGAI9H@1ODiAREVKBU*RqTMkH^~2 zKgb(*10Z%yXHsAy4euz7-k4c1O!G7)A_y;*vapyxC{zH9daQ!PGIaNB3KEmV=7nZ$ z3v-fAeEbC-7Pa(l2(dlt?vnKw_auLbzL|Vb-D*4Me2E_C5DP^(2#X_xcv$ySiAS`Y zBCpDQQMTB=vAVPzKnLrSz5?+$>z5JrP2#=RrC%D;Z7#8zlk_a&me>+q39thloo2HT z;L8t-K>B+$?Co6AfPdA>RFWdX95Mn~i&WD!i*vzNdbGXPpQDD>;Od{hyE9f>QB3#) zz$FJSC>@Tguhd*r?UFgS75D?St6rkP?gS)^%a6u^SfIB>_ip=sMA{pZF4OJ-9w%9O z7Cf(~1@amU#9S@$vH*PpJpx!C~WO6`j}OIiT}XpMyXz#kG(@U~W;V@y{iN zyh=Q^;9#1Cgwtk2_)(&i4lcb|?h=k=8=AAf6<94N##ufx&QpTiSD8PQ=2M}GzX`Fe zIuf~zx>q?6Pg}=iO1h1kLsL3>xuezK9lH@_>n^cE*}kDqsub0xsqQULwt6iV4nFaL zHaH$(;7+8Jo5}XM75+@?R)e&7_NB7UPH|usVbJxpiNI^`-eR{!la8(0dEDy#! z_@?UVY4VwyQXneePK^5tEP83BwA?3WTUWPIF!lnkaM*|W3Yb~C=OETSCb#SYF-MCg z>kgDauk;Y_OH|Edqu!+ZV*qfDrdyN~6rS3g1E^ozAW(*UM{{&-ndytQ6OghaGT zHjRF~r7>SPV3iJ@i9deG@pxSN-TTH0PBA7q--7s?V+8de1%afvwcCQrEHg*n!9GTm zp6KHe7iZe=2OR|&b)x5mnOqfe=YHIGG|02laxfpOLI9)LTb3hQ&-;l^VzZFQooLwB z2ZHROqfPPS4za1{fTfk?J1TTsv%Z& z4tx^LvBL^r9*ZC#hf`Fs^y`ZtKDnLo*{%n;NN`+3JT@63TJbllL2CS3sv4@cpFRG1 zX?EN{MY>6yZ*S-;2?B?`bhSR$d+g874%~V+N`;tWXVuOW04acPAOpqKHvZH{SdoSD z2f0#13Hl!sm01iN8m_`XU)NLh2cduaPWX`@ZYLVtYtED~*o#_ockWZBxIz%{({9yh zHP7Ih|}r{?$~*|Wo9A*(h&*GgCh&Q<)$R@FJSE^=MN6M;LQn>6OHA> zC=`BT7||o9kCL`?Q8@>h)Br_MRVIcF=KSY{mL7+fJwK>Y@6h^jo*>|O-?J(<6rzl5 zn?9y`YNB5E=&Rc+2`2-Wi<(-oUpan-*08_?e)|II`#WO47(R`v&>9oATreBF3}Nds zyvDlNgS~QiwPgve_L3AqfQV;!$W2C>xDp(0(S!t|UR!S%s{eYOFZx zQpQI6l{8RPd`LT{14Rpqq&DJY@yEBdOo#~-@)iA#ZBjT2i)9i=BDKU<|7=5$cAKtfb3xO1S(8sSq#cz>8w&7x}?cc!`r<%k{W^a*X>GnxeF2wLb>05 z!z}Yn)w-=ixS`FsKV@fJs7g?j+zUL?}pY_3qL2+S7kNbmuZ z^){!8`aw&4WVJnn79hlR=Aw01(lP!(roiBo1%Pw88|zgo^_`$&{R)n~94kb>D6<~e z>WJ(z6&f65ot<2^Xva6JT+5O{mg$dOGK3=^3rrKz+kUFOao_sD&{99ApI@zMH#xz%3uk=%L4fc$?Q2&hP5t; zY77A-B>P67U2BLEvuhfEFd`0Zi4m{b4ORSzZ%kI0kTLda7~0J zmfS)j-@WwM{yg&}f0e#J&r_8m^>LJ90)$d?tMKKXsk#kWsO(bylC@k%RO{c3EhiND z1_ly*lRvbB0HQk-xYD(`Ry{KGer-e-E~5--*aXd`T?kZ&1wZ)ZdoJMexUfQbp@}>K zo$1t=0Yl}lNS)3;@AQeEq8-cMeDcd`N?cI z*m-E`{pDr_q(Xa-13lioR}<6m+gLzTDuz=gIT+mBFI`*{i>T9u{r5_^n0imE?q^*c zhknQFjhWvJvYB^z2-LL-_y`C{O?}s|rgz?!g3YxqsmCBh5LH#D%^NE7cD?K5W>>_J zmzn#rWLsx-s5a3X0(VaPThn&J%tQk3OH9A<_HvN%F=Fqsr%IKZ~3Am+!dH z3~BOIU?poosIGdB2^KGl2t-duq~uCrKPJVHt@3*JBRLKCiRo!h1DPX2>({SeJ3Akp zt$N*3luMg4FLAD~rLsnIH5fy60rAQh6(BP4NSi=|kbq8i?e)y-f&{D)ZoB81$EYmA zL3!ASqIPDcc47AS?^wGkyIf0yLanW@!zTJ|6lCP{_VQYByv@nU&oiCrFgW=^kD~h} zZ=lV$9nUVkbrg0__ruoV#P3U4gUzjyxD`C_SrnYi<=qdm2Dc)jY1Pl3?Pg$Ny3ccV zE>qIZ)6sSYKMjD1O@*`VgB*MasVNQ_I%yg5^fvujAh4o#l7Q`9-+R*6k|0GkKr0SU%w5 zR&=d2igetMpeJ+W)Uyo;3u}J26Xut-NFcAKaC)BEu&jEtHK0ahr!YA$bMf@xD)-mY z+ss3JF8-y~je6G%jk?toDoCjBs#imq4wlLt4AkmqiRQ7LXb2!V2>f(TJA&f{AGu_D zLjJ$sY$TM{jR#ku%ciCy=p!gk=TgaiW1HxWXoaB})z4QiKB5Au?r+W`o$N1_NeG?8 zry43YRRi9HOK^3$4lK+lt-6n-_jNw@-+@oC+`9)OLk6;~bqlnRKU?#ctJ`Ep;j9f7 z)_IvzeLmEpZMkyVrwy6J)Q7b%WR@|yS%=c*F6Eyo(nsc)aYh|Z^uf(p#EFN?eRzPp z;Nm6);4}Cc<_cSz@UIgz6#STipd0*X22=_ocGR|Jh`Hwcp@c#3KSI$@1nVYZH`I5)lPlZD{E*H2_4=?L0vDB543e;vG z3adUtch?P@^=_{l{AQ-2tsNUwp`WI06A~GTWTp%W4h{|q8nPSrg<&yP<5dZhr z<#IB0>uj4q>88r#qy5fg@xgNY0*70ytgN?g-QwYSGhAv7nP(xNd-Q$X7owux!s!3) z{kb<6zCNj4wDG6r=ev0`9-h3f)2!E{9cHz1DX(Fy0*p`&%;WzRhI)*Z`*~&;^mh@t|EX7SoexOi+C`baDClvh7Lk7Vo zEyAi#k%rAbg0_8E-gHW_FLh{sd;9iun^Ptww^bOwOQi#nYhF&-91hmJ>;503m0b9$ z`Vi7hva(853`1BEHZRhPeSCbJ%B3Ry$HN+ol}{#Nj|v`gKH8loVN#s1DdY7gAtp{r zObiMRE*V}zGAA7FZF&TC%Y|2;DH2vcR>OgzExBdcpUi8YKbIu4(*GGOX`<-o$B!TH zZOj%xae&_mBGeDbBGoG%MjEwa?@6?w!@*z$V;&Wh=_8Y&=7kTZ^5xt*lsZ+N8(oSgcA?+3iy62T6IydbdIEh(#}4Kv5MwUpvP55w3>i`-X$ zSGQc$mlwpv3WpTR^(kSH)JP#^Z7~{c9ba7%uZK zZ?>0WL_RVxpiY+L8a@RTm2>wix!q%KxXVmjX%1YU)F}H6UUS#&kh;%Sp`Sjh5Me@z zu)FbbadA+h4=GjzDMX=OnmwFWdJadBKr^GHq@?n}j6vBzrFcfS3lw&4H9vm*?FV75 zec!^?J%tJfYbmJ?8vlnV)So|pzx6`XVPSd!`7rjlb5sBQ(a)bhgPuqp z$+p^%G&9TXWuevs6?7WMYz&mS`3WB(QrFX(zn`kdz4vs~``MWu#gOXKrUB-=y#luX zO$|Be&K}mXQ#vIs3(@|gag9mB=HvcT8HQJTib|yL?y_@+V5BV={KUBt@uSrDvx7OP_=N7@O5Q z8JqK8J(yVrNifNOWo}|1hc*0zJRZ7jF-!~$O5e=>gx%A65zD8tYFW;;mnzkdW_V-! zh0v|=T(&q+RldIcuK-}Q!mGC~JMtx*R?E$-#U|G?RZQ2f*n#BM$flyn&^IU&N6G78 zwa}Lbf?XH#N={is`!qRKAO8vKgQ{fA-ORyuTO^mahw?|jwK_)XPTGaCJsPHp1{I(> z7Zq){jvXDLC><146Pkc3kiH*2pcxo4q=cd<(2`P zn(9?5pe@3x6`Kg+@^*fVWKw;Xfw{UvCdw0FB*8rVSn2!g1-lC8I zOpLLA1LhZs^o;cv=;Gqy4x1F8Z6fST{3H(|G> zp+0J_(I6``@`9lZKqoc($zbtFjzYcPnxLNMWIBpR91KAq2?z)(-fUL_1jv#OspbId zjSLoqTNpjaoa=S<+BN5`eqCG=W+SNg!Mi;~L`li3@#7sNv&|L2j%_vUm60_BPm^+| zf3#xft9~a2gL&dl<+LN$+>vn56n`+;{<2ZMSfa z9*k2Td-?mfH8z%x`|<2;FE49}pRAtls9Wq|#goaYNKv8s#T(r+C8?=10FsEQ?=Uf`z)F8JALl#T(ZXghL>sDWANNIu%q^{tz2 zWuuPkpxq1^mHtlszXKPLkCn#V-CYoy3K$lw$p}1-!Dw#URXA*dOtC{Q=KvZJGI<4Q z1wr(Fc%3Rz906+5#lS)U??UChZg)XXJSVvA50}vsMtH4eUV@5Yg4gvN*Ae`+@=Wp} z)kWu(e?zrVL|H;vQ>&43``Ou9$emr@n{*(ppr+2>S(x$%Y93L6R)%v5*O(L&ladZc zj}O(JKYtEd4~mI>f@8)5hB=h{&Ihr_2fDBuzf?M(J+RC*KAn$xtj^#vj@V$Jeh;Hz zS(OOhC1qvhhrlx+D_|SE8pPn3EEwjV95w!j4-X*W&@pne|Ky*cLRKjl9r9N_%4FMY z2kWuN4=tyjx0Qi~xz24h1Kv%>$Z(a*F_eRWs;Ndku+R&fekTy-g3%=WWibYIT}V{z zG$4VIy}dnH6PhO<`zqcPPbW}AW^kDGoZ|YyKd%6bU$}u>BZIoDEVpl`?u-FBf%w40 zlv0R>LgeUQ>u%JE2tkqNA=GT;+v?MT<$}|%q@XHfWkdGbA(3~P$uRe5696XtbR%^I zqS;bg>EZS{d*jEV&z{Zo<&B3hDN+1iy6vI4eS4-~H`dqNJDgR!QJizwYBE@kZ>1U= z3fj1={h|jG_hxf$@{L0AAg|quI%V+XOGeC06?RXbK8@rs22!fcuI}vYL`6|}2K}RQ z|2=`huZ(V+!A!Srhw(Y>!6A!c0)31zO|<%Ci*Cq(K2x zA)%jYM{A*gTkC)R1iW@I8LDOi#dyI%jt1p{m})SwJ~9YKHbiF>Q7Dwz7(>QwRmfsR zZu0FWAKy`P_C3&z!BGTu9JL0j z+6m5o1LT8G|G1vQ29rR1b>cAnx`eh;5bX(oss#H1Ix7~N=b$)g@pLSd8iqvZ5-(mj zkGbKX`Cxw`CeU3qjpB#%#(j;KO3Ap^MShrS2Y;rQ@FQcNi?$uf6IRFy zZke2q@h~Ya!|?HdVWE0p@w%2)eB{pU+erPIQKxN{8U{H{^)bi@0w|4zp1!`XF<1|6 zxLOUTwvl?q@c(PONx-6&+aqs~bL+|Ck(U^iXa&)7WadwTHwV4$-_3P-5>@NzQQ0_+ z&z*33-?*LhRfs^!-5D#n9?luG0w}#~m9nluQc_YH5kE&b2mdiEGcEH^Fw>9TO$@Mb zCsHr-=y0D?ul7O- zhS`!i3)hM58iVeD`|0Q7!+=HuYGO@X1Kl*@v^^}rw>tsq7Ygi2nQ;)O|joPeh0m!`?Jd!4|Y z!|AUwM5yeAx}m_HI0QYw*!AaX2<8}ugoL~RvonUT?PA+KH$ga)j#?TCGfu+KGc5INB+V99`jz3?Akjh0yc zD(IFuw5h%k;1&_xb3O3lpcO(`+fk=tfY(spkYhqb6F!IT=SHiJkt)}!HvQUjp+U>R z;C{UV1EAlL2oRbR!bsqmz$>>Zj6_9$y}LjJH4z=^DQy|mD&ZhnF=T)Ns=NFom6H7z zeUI@=q2<1OPa8tqBMK{zGdk`4=I#1>_jNl3k316r*N^MT>w#fy;4E+8r@^JsHJQbC~~ zC~Q(Xn_TNd(iVQde$v7TFiBck8Zuyq!G^D2zfwEzXsk{Af;4)NT?cVHplY*Xp#d=& z*__q@G=oy)8702&-o1k(ekqBGG6SDSal!Z_%LURt0@XR%+uOlZs{noYdSoi0g5ZxI zk2eP=JjhLUpqPbRwz7TAWzKh=p8HZZNCKbjC8xQx6h$b8mLd@gwame~#mnWW!T>6D zUr0z)!A!GDrXs$5`v%|q0e8sX*LSmG0R%iKIy$TM?D-296!Kq#4f?RG4RyKg;BpA~ znJ5kp4m8>Tl#xoQ)df5}j@O;iWgoem++TEhU3^s(iiL&SFDfJ;qt*WEN8Gx4d%sD$ zKACwDxfL)0_(00wd^$SX`^yary@{@jEOfEX$mYKVKbk&K^UpUl^38Ut)Ic3}gpoku zlX03H3|Y2isa7~lHU{(Q0F$_cGONIN-v=|uwT9oH-iD-vgQiu{At6OzMso}SDMazu zz7T@qGu%+2%ZIsC4{{2Eulp*pZXeVs0Z3$y0}mV92Fw^(aoH&|t+l_gsY%)tIZm1( zn?(izRV4M^o3Zlphi#Pg0s;cw-rj(h4xmcLoK`;+b}t(?X)D9V!7E>HQBpw2eFKWJ$XU_OzemVXq)x#;QIZ4h+hk@9eX>F;p`!*QK#7^Ce_6VP>t^9)t! zLHChtKJ};KbyVxk13^tpOaw`v2vIVzpNaPb9@Tg{3D5@7MMxM2Ac3^BG{g{I%cv`@ zq>(HGzJLPGP@+Bt0zF`*Uqo{k0<|i2BOsfjtZdKW_Gpf3G%WN|Z%!-=e-M-6*DC=K zUxD+g3*B;R6^C1cu~1XqWVkfVw8$;HY^fAhZci0hj?Z8}W_7%N8RXj3WqahoZCu9HzM4IR4_B%IJ9P6) zQt{z&YeVb_7$gdgjR70mEDgyxkJOto4Zb_ck za_Zx>%Yy5)Gz%5jM)1|6qbq>0Gcz+_U4Yr^w_UD4Rf|ywszLFn3|lcVF;1gCFg;3Y z27IeOi6HQ*sjr_lU6_aj1|UMh_arM3l<;7&Y0i#Ifh`J!g6ev^?SlB=u*z3lze-4G zu~^tk1#zwVMp)RL8mNb|P|!BwyuawO*BOhPAcjB&6a*Stcd=Z2W#s?X-JM=Mg3Wra z3+#mh-yb0ENP(`u?Sr;s&k-#kE8-0T`nt?WPC=2$@jQ%G`x(Rw9WGaFp;bTR=H|k5 zyq_8wEr2T#FI%i;-Tf-&&ZFf<^b_B2&w9V763E@jb#u=yHI4G8$<|cI#!*R{S09VZ z=%XU#nMseDUt4i#?{3HJgLzcFh>bJ6dpY%{`_jR@dKa=JsRD8}oUQc&a`%ud0Rj>h;IZg z5@N#P$H=EitGT6RP#dOmU1{J|nmvB}1F7RJ_8lA(s5)5ViAT8ywz>=M(^Vbzq;AGh zfxH9y*wS9HW7BI#0zoFlN{1aR;P3&00I6AggVY6g{{2N5DNqWM!3;nOj%^@+av3{uYbO}2&w)vmdK~ay-w(4d&JrLNx;_m&FitavN7dQTleg#Yoqf5a)K$T zcAPWm4Dp5pP{O7HiFRAn)wxwD@wfykY~@dlsHiA_ zan8idn>5*_^JC*)KoCjyY!A~Y7Mkovh6Ny5e&(`!YbRA2zuVu4U8JNt^MePweR*1Q zi_a`ozJLF&dQ}EpxB~&moE^y=9F^eE$4(8Q0&*$}qc|#;d%wf4+UkPmR9Z&4;9G0C zKHz3;g;9~H6qRcM;K@UvX%(~#z$#e2BeoUr4WU>vxI9&Myq>Sz0W^n(gdF|BcR?zr z^S%+;Yh9n~K@cD)%F@cxSCUC`#a-@lD%Ra#cs@Qe)US*!3l9y|ZFinNGJ}u^5sY`JAq!w3kEZ@2i_*wqyDl+FqKkOJhEAe)=f;sc10LqZ*eg( z9*YSt$pH3+SzB|8Z98w z8%iLjq7G`-THA@j3QqUOa2Q88>;v6llV{bjp-(_gMkXjQ@XOIpMAyyt=4_YB=;%P) zr7~@#_17;j6{*vA97(O%l`pEDQ@M=v!mfss(|rY@p`|*I^Rk;0d>yo@{SyGi9$a0J zF+E;1bs^_--0>oy@xZ5!2??3nk7KxfyRXPNl3UCIwr1#GMBQd!Kr$Rd1Fdw?v=7Lw3 z|J(Yd(bc}{{D{30^e}+?IrRn*m+P-OO_?gY8)Y>Q=U;knVjH68k8SJ$V?o)VP8sFT za7~!Z`Su~ta`}4m{k9=+V?d`c-MMqG=-b)fJ~ekE2DI@hkJ}-FlyFS((8!AND*4$S z$)4)1)MpV>rHmt({rF7Hg!#C>+qp~e@i{6*vuwF6EO+GU>qCQ#bs>_O19y_NL47AB zC6RNQu$eI_3cH`XoG+#z9{PBmxaefm@*)b@q587FLI>#$;!JuLhDz4Kb<&d&|IcUU ztylJ*97OEe=?0Xv@_W_WUbt`p>>yitX7GRR&dsUAo;84e)*{|D$k-g_$Daq0zOvcm z$V;ZH>?#+X%yYcx;i#|OVFvH9WXA=Xxc z{+#0P-(Efqpi{G9hfyqk`QqR_cOP@EUp~yDqnDY-jO=H`<285+? z^^iS6I8*}(GcO*v!zt#LSFlV!FpefASn0HOIm66oRzy=RDIy aj`&~G#tal}ta-whSfau>8y2zx`h=Ox
str: + if not value or not value.strip(): + raise serializers.ValidationError("Property name is required") + + cleaned_value = value.strip() + if not PROPERTY_NAME_REGEX.match(cleaned_value): + raise serializers.ValidationError( + "Property name must start with a letter or underscore and contain only letters, numbers, and underscores" + ) + + return cleaned_value + + +class EventDefinitionBasicSerializer(serializers.Serializer): + id = serializers.UUIDField() + name = serializers.CharField() + + +class SchemaPropertyGroupSerializer(serializers.ModelSerializer): + properties = SchemaPropertyGroupPropertySerializer(many=True, required=False) + created_by = UserBasicSerializer(read_only=True) + events = serializers.SerializerMethodField() + + class Meta: + model = SchemaPropertyGroup + fields = ( + "id", + "name", + "description", + "properties", + "events", + "created_at", + "updated_at", + "created_by", + ) + read_only_fields = ("id", "created_at", "updated_at", "created_by") + + def get_events(self, obj): + event_schemas = obj.event_schemas.select_related("event_definition").all() + event_definitions = sorted([es.event_definition for es in event_schemas], key=lambda e: e.name.lower()) + return EventDefinitionBasicSerializer(event_definitions, many=True).data + + def create(self, validated_data): + properties_data = validated_data.pop("properties", []) + request = self.context.get("request") + + try: + property_group = SchemaPropertyGroup.objects.create( + **validated_data, + team_id=self.context["team_id"], + project_id=self.context["project_id"], + created_by=request.user if request else None, + ) + + for property_data in properties_data: + SchemaPropertyGroupProperty.objects.create(property_group=property_group, **property_data) + + return property_group + except IntegrityError as e: + if "unique_schema_property_group_team_name" in str(e): + raise serializers.ValidationError( + {"name": "A property group with this name already exists for this team"} + ) + logging.error(f"Database integrity error while creating property group: {e}", exc_info=True) + raise serializers.ValidationError("Could not create property group due to a database error.") + + def update(self, instance, validated_data): + properties_data = validated_data.pop("properties", None) + + try: + with transaction.atomic(): + instance.name = validated_data.get("name", instance.name) + instance.description = validated_data.get("description", instance.description) + instance.save() + + if properties_data is not None: + existing_properties = {prop.id: prop for prop in instance.properties.all()} + incoming_property_ids = {prop.get("id") for prop in properties_data if prop.get("id")} + + # Delete properties that are no longer present + properties_to_delete = set(existing_properties.keys()) - incoming_property_ids + if properties_to_delete: + SchemaPropertyGroupProperty.objects.filter(id__in=properties_to_delete).delete() + + # Update existing properties and create new ones + for property_data in properties_data: + property_id = property_data.pop("id", None) + if property_id and property_id in existing_properties: + # Update existing property + existing_prop = existing_properties[property_id] + for key, value in property_data.items(): + setattr(existing_prop, key, value) + existing_prop.save() + else: + # Create new property + SchemaPropertyGroupProperty.objects.create(property_group=instance, **property_data) + + # Query fresh instance with properties to ensure all data is current + return SchemaPropertyGroup.objects.prefetch_related("properties").get(pk=instance.pk) + except IntegrityError as e: + error_str = str(e) + + # Handle duplicate property name within group + if "unique_property_group_property_name" in error_str: + # Extract the property name from the error message + import re + + match = re.search(r"\(property_group_id, name\)=\([^,]+, ([^)]+)\)", error_str) + if match: + property_name = match.group(1) + raise serializers.ValidationError( + {"properties": f"A property named '{property_name}' already exists in this group"} + ) + raise serializers.ValidationError( + {"properties": "A property with this name already exists in this group"} + ) + + # Handle duplicate property group name + if "unique_schema_property_group_team_name" in error_str: + raise serializers.ValidationError({"name": "A property group with this name already exists"}) + + logging.error(f"Database integrity error while updating property group: {e}", exc_info=True) + raise serializers.ValidationError("Could not update property group due to a database error.") + + +class SchemaPropertyGroupViewSet( + TeamAndOrgViewSetMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + scope_object = "INTERNAL" + serializer_class = SchemaPropertyGroupSerializer + queryset = SchemaPropertyGroup.objects.all() + lookup_field = "id" + + def safely_get_queryset(self, queryset): + return ( + queryset.filter(team_id=self.team_id) + .prefetch_related("properties", "event_schemas__event_definition") + .order_by("name") + ) diff --git a/posthog/api/test/test_schema_property_group.py b/posthog/api/test/test_schema_property_group.py new file mode 100644 index 0000000000..81c361b77a --- /dev/null +++ b/posthog/api/test/test_schema_property_group.py @@ -0,0 +1,248 @@ +from posthog.test.base import APIBaseTest + +from rest_framework import status + +from posthog.models import EventDefinition, EventSchema, Project, SchemaPropertyGroup, SchemaPropertyGroupProperty + + +class TestSchemaPropertyGroupAPI(APIBaseTest): + def test_create_property_group_with_properties(self): + response = self.client.post( + f"/api/projects/{self.project.id}/schema_property_groups/", + { + "name": "User Info", + "description": "Basic user information", + "properties": [ + { + "name": "user_id", + "property_type": "String", + "is_required": True, + "description": "User ID", + }, + { + "name": "email", + "property_type": "String", + "is_required": False, + "description": "Email", + }, + ], + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "User Info" + assert data["description"] == "Basic user information" + assert len(data["properties"]) == 2 + assert data["properties"][0]["name"] == "email" + assert data["properties"][0]["is_required"] is False + assert data["properties"][1]["name"] == "user_id" + assert data["properties"][1]["is_required"] is True + + def test_update_property_group_preserves_existing_properties(self): + # Create initial property group + property_group = SchemaPropertyGroup.objects.create( + team=self.team, project=self.project, name="Test Group", description="Test" + ) + prop1 = SchemaPropertyGroupProperty.objects.create( + property_group=property_group, name="prop1", property_type="String" + ) + prop2 = SchemaPropertyGroupProperty.objects.create( + property_group=property_group, name="prop2", property_type="Numeric" + ) + + # Update: keep prop1, modify prop2, add prop3, delete nothing + response = self.client.patch( + f"/api/projects/{self.project.id}/schema_property_groups/{property_group.id}/", + { + "properties": [ + {"id": str(prop1.id), "name": "prop1", "property_type": "String"}, + {"id": str(prop2.id), "name": "prop2_updated", "property_type": "Numeric"}, + {"name": "prop3", "property_type": "Boolean"}, + ] + }, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["properties"]) == 3 + + # Properties are recreated with new IDs, check by name and values + prop_names = {p["name"] for p in data["properties"]} + assert "prop1" in prop_names + assert "prop2_updated" in prop_names + assert "prop3" in prop_names + + # Verify types are correct + prop2_updated = next(p for p in data["properties"] if p["name"] == "prop2_updated") + assert prop2_updated["property_type"] == "Numeric" + prop3 = next(p for p in data["properties"] if p["name"] == "prop3") + assert prop3["property_type"] == "Boolean" + + def test_update_property_group_deletes_removed_properties(self): + # Create property group with 2 properties + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + prop1 = SchemaPropertyGroupProperty.objects.create( + property_group=property_group, name="prop1", property_type="String" + ) + SchemaPropertyGroupProperty.objects.create(property_group=property_group, name="prop2", property_type="Numeric") + + # Update to only keep prop1 + response = self.client.patch( + f"/api/projects/{self.project.id}/schema_property_groups/{property_group.id}/", + { + "properties": [ + {"id": str(prop1.id), "name": "prop1", "property_type": "String"}, + ] + }, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["properties"]) == 1 + assert data["properties"][0]["name"] == "prop1" + + def test_unique_constraint_on_team_and_name(self): + SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Duplicate Name") + + response = self.client.post( + f"/api/projects/{self.project.id}/schema_property_groups/", + {"name": "Duplicate Name", "description": "Should fail"}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "already exists" in str(response.json()) + + def test_property_name_validation(self): + response = self.client.post( + f"/api/projects/{self.project.id}/schema_property_groups/", + { + "name": "Test Group", + "properties": [ + {"name": "123invalid", "property_type": "String"}, + ], + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "must start with a letter or underscore" in str(response.json()) + + def test_delete_property_group(self): + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="To Delete") + SchemaPropertyGroupProperty.objects.create(property_group=property_group, name="prop1", property_type="String") + + response = self.client.delete(f"/api/projects/{self.project.id}/schema_property_groups/{property_group.id}/") + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not SchemaPropertyGroup.objects.filter(id=property_group.id).exists() + # Properties should be cascade deleted + assert not SchemaPropertyGroupProperty.objects.filter(property_group=property_group).exists() + + def test_list_includes_events(self): + # Create property group + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + + # Create event and associate it + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + EventSchema.objects.create(event_definition=event_def, property_group=property_group) + + response = self.client.get(f"/api/projects/{self.project.id}/schema_property_groups/") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + property_groups = data["results"] if "results" in data else data + + test_group = next((pg for pg in property_groups if pg["id"] == str(property_group.id)), None) + assert test_group is not None + assert "events" in test_group + assert len(test_group["events"]) == 1 + assert test_group["events"][0]["name"] == "test_event" + + +class TestEventSchemaAPI(APIBaseTest): + def test_create_event_schema(self): + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + + response = self.client.post( + f"/api/projects/{self.project.id}/event_schemas/", + { + "event_definition": event_def.id, + "property_group_id": str(property_group.id), + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["property_group"]["id"] == str(property_group.id) + + def test_prevent_duplicate_event_schema(self): + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + + # Create first event schema + EventSchema.objects.create(event_definition=event_def, property_group=property_group) + + # Try to create duplicate + response = self.client.post( + f"/api/projects/{self.project.id}/event_schemas/", + { + "event_definition": event_def.id, + "property_group_id": str(property_group.id), + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + # Check that it's a uniqueness error + response_data = response.json() + assert "unique" in str(response_data).lower() + + def test_delete_event_schema(self): + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + event_schema = EventSchema.objects.create(event_definition=event_def, property_group=property_group) + + response = self.client.delete(f"/api/projects/{self.project.id}/event_schemas/{event_schema.id}/") + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not EventSchema.objects.filter(id=event_schema.id).exists() + + def test_cross_team_property_group_rejection(self): + other_project, other_team = Project.objects.create_with_team( + organization=self.organization, + name="Other Project", + initiating_user=self.user, + team_fields={"name": "Other Team"}, + ) + other_property_group = SchemaPropertyGroup.objects.create( + team=other_team, project=other_project, name="Other Team Group" + ) + event_def = EventDefinition.objects.create(team=self.team, project=self.project, name="test_event") + + response = self.client.post( + f"/api/projects/{self.project.id}/event_schemas/", + {"event_definition": event_def.id, "property_group_id": str(other_property_group.id)}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "does not exist" in str(response.json()) + + def test_cross_team_event_definition_rejection(self): + other_project, other_team = Project.objects.create_with_team( + organization=self.organization, + name="Other Project", + initiating_user=self.user, + team_fields={"name": "Other Team"}, + ) + other_event_def = EventDefinition.objects.create( + team=other_team, project=other_project, name="other_team_event" + ) + property_group = SchemaPropertyGroup.objects.create(team=self.team, project=self.project, name="Test Group") + + response = self.client.post( + f"/api/projects/{self.project.id}/event_schemas/", + {"event_definition": other_event_def.id, "property_group_id": str(property_group.id)}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "does not exist" in str(response.json()) diff --git a/posthog/demo/products/hedgebox/matrix.py b/posthog/demo/products/hedgebox/matrix.py index df95576580..17c763e3ca 100644 --- a/posthog/demo/products/hedgebox/matrix.py +++ b/posthog/demo/products/hedgebox/matrix.py @@ -45,6 +45,9 @@ from posthog.constants import PAGEVIEW_EVENT from posthog.demo.matrix.matrix import Cluster, Matrix from posthog.demo.matrix.randomization import Industry from posthog.models import Action, Cohort, Dashboard, DashboardTile, Experiment, FeatureFlag, Insight, InsightViewed +from posthog.models.event_definition import EventDefinition +from posthog.models.property_definition import PropertyType +from posthog.models.schema import EventSchema, SchemaPropertyGroup, SchemaPropertyGroupProperty from .models import HedgeboxAccount, HedgeboxPerson from .taxonomy import ( @@ -947,3 +950,56 @@ class HedgeboxMatrix(Matrix): ) ] team.revenue_analytics_config.save() + + # Create File Stats property group + try: + file_stats_group = SchemaPropertyGroup.objects.create( + team=team, + project=team.project, + name="File Stats", + description="", + created_by=user, + ) + + SchemaPropertyGroupProperty.objects.create( + property_group=file_stats_group, + name="file_size_b", + property_type=PropertyType.Numeric, + is_required=True, + description="", + ) + + SchemaPropertyGroupProperty.objects.create( + property_group=file_stats_group, + name="file_type", + property_type=PropertyType.String, + is_required=False, + description="", + ) + + SchemaPropertyGroupProperty.objects.create( + property_group=file_stats_group, + name="file_name", + property_type=PropertyType.String, + is_required=False, + description="", + ) + + uploaded_file_def = EventDefinition.objects.get_or_create( + team=team, name=EVENT_UPLOADED_FILE, defaults={"team": team} + )[0] + downloaded_file_def = EventDefinition.objects.get_or_create( + team=team, name=EVENT_DOWNLOADED_FILE, defaults={"team": team} + )[0] + + EventSchema.objects.create( + event_definition=uploaded_file_def, + property_group=file_stats_group, + ) + + EventSchema.objects.create( + event_definition=downloaded_file_def, + property_group=file_stats_group, + ) + except IntegrityError: + pass diff --git a/posthog/demo/products/hedgebox/models.py b/posthog/demo/products/hedgebox/models.py index 548112e600..7a1b494b3e 100644 --- a/posthog/demo/products/hedgebox/models.py +++ b/posthog/demo/products/hedgebox/models.py @@ -582,7 +582,11 @@ class HedgeboxPerson(SimPerson): if self.cluster.random.random() < 0.7: self.active_client.capture( EVENT_DOWNLOADED_FILE, - {"file_type": file.type, "file_size_b": file.size_b}, + { + "file_type": file.type, + "file_size_b": file.size_b, + "file_name": self.cluster.random.randbytes(8).hex(), + }, ) self.advance_timer(0.5 + self.cluster.random.betavariate(1.2, 2) * 80) self.need += (self.cluster.random.betavariate(1.2, 1) - 0.5) * 0.08 @@ -684,14 +688,14 @@ class HedgeboxPerson(SimPerson): assert self.account is not None self.advance_timer(self.cluster.random.betavariate(2.5, 1.1) * 95) self.account.files.add(file) - self.active_client.capture( - EVENT_UPLOADED_FILE, - properties={ - "file_type": file.type, - "file_size_b": file.size_b, - "used_mb": self.account.current_used_mb, - }, - ) + properties = { + "file_type": file.type, + "file_size_b": file.size_b, + "used_mb": self.account.current_used_mb, + } + if self.cluster.random.random() < 0.5: + properties["file_name"] = self.cluster.random.randbytes(8).hex() + self.active_client.capture(EVENT_UPLOADED_FILE, properties=properties) self.active_client.group( GROUP_TYPE_ACCOUNT, self.account.id, @@ -709,7 +713,14 @@ class HedgeboxPerson(SimPerson): ) def download_file(self, file: HedgeboxFile): - self.active_client.capture(EVENT_DOWNLOADED_FILE, {"file_type": file.type, "file_size_b": file.size_b}) + self.active_client.capture( + EVENT_DOWNLOADED_FILE, + { + "file_type": file.type, + "file_size_b": file.size_b, + "file_name": self.cluster.random.randbytes(8).hex(), + }, + ) def delete_file(self, file: HedgeboxFile): assert self.account is not None diff --git a/posthog/migrations/0889_add_schema_models.py b/posthog/migrations/0889_add_schema_models.py new file mode 100644 index 0000000000..c7ff5b9e23 --- /dev/null +++ b/posthog/migrations/0889_add_schema_models.py @@ -0,0 +1,155 @@ +# Generated by Django 4.2.22 on 2025-10-02 22:57 + +import django.utils.timezone +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import posthog.models.utils +import posthog.models.property_definition + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("posthog", "0888_datawarehousemanagedviewset_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SchemaPropertyGroup", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("name", models.CharField(max_length=400)), + ("description", models.TextField(blank=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_schema_property_groups", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="posthog.project"), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="schema_property_groups", + related_query_name="schema_property_group", + to="posthog.team", + ), + ), + ], + ), + migrations.CreateModel( + name="EventSchema", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "event_definition", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="schemas", + related_query_name="schema", + to="posthog.eventdefinition", + ), + ), + ( + "property_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_schemas", + related_query_name="event_schema", + to="posthog.schemapropertygroup", + ), + ), + ], + ), + migrations.CreateModel( + name="SchemaPropertyGroupProperty", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("name", models.CharField(max_length=400)), + ( + "property_type", + models.CharField( + choices=posthog.models.property_definition.PropertyType.choices, + max_length=50, + ), + ), + ("is_required", models.BooleanField(default=False)), + ("description", models.TextField(blank=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "property_group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="properties", + related_query_name="property", + to="posthog.schemapropertygroup", + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.AddIndex( + model_name="schemapropertygroupproperty", + index=models.Index(fields=["property_group", "name"], name="schema_pgp_group_name_idx"), + ), + migrations.AddConstraint( + model_name="schemapropertygroupproperty", + constraint=models.UniqueConstraint( + fields=("property_group", "name"), name="unique_property_group_property_name" + ), + ), + migrations.AddConstraint( + model_name="schemapropertygroupproperty", + constraint=models.CheckConstraint( + check=models.Q(("property_type__in", posthog.models.property_definition.PropertyType.values)), + name="property_type_is_valid_schema", + ), + ), + migrations.AddIndex( + model_name="schemapropertygroup", + index=models.Index(fields=["team", "name"], name="schema_pg_team_name_idx"), + ), + migrations.AddConstraint( + model_name="schemapropertygroup", + constraint=models.UniqueConstraint(fields=("team", "name"), name="unique_schema_property_group_team_name"), + ), + migrations.AddConstraint( + model_name="eventschema", + constraint=models.UniqueConstraint( + fields=("event_definition", "property_group"), name="unique_event_schema" + ), + ), + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index 25250d9bc8..63e139b3be 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -0888_datawarehousemanagedviewset_and_more +0889_add_schema_models diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index f854dd816b..1b8798d210 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -68,6 +68,7 @@ from .property_definition import PropertyDefinition from .proxy_record import ProxyRecord from .remote_config import RemoteConfig from .scheduled_change import ScheduledChange +from .schema import EventSchema, SchemaPropertyGroup, SchemaPropertyGroupProperty from .share_password import SharePassword from .sharing_configuration import SharingConfiguration from .subscription import Subscription @@ -169,6 +170,9 @@ __all__ = [ "ProxyRecord", "RetentionFilter", "RemoteConfig", + "EventSchema", + "SchemaPropertyGroup", + "SchemaPropertyGroupProperty", "SessionRecording", "SessionRecordingPlaylist", "SessionRecordingPlaylistItem", diff --git a/posthog/models/schema.py b/posthog/models/schema.py new file mode 100644 index 0000000000..e8bc8a7d06 --- /dev/null +++ b/posthog/models/schema.py @@ -0,0 +1,123 @@ +from django.db import models +from django.utils import timezone + +from posthog.models.event_definition import EventDefinition +from posthog.models.property_definition import PropertyType +from posthog.models.team import Team +from posthog.models.utils import UUIDTModel + + +class SchemaPropertyGroup(UUIDTModel): + """ + A reusable group of properties that defines a schema. + Can be attached to multiple events via EventSchema. + """ + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="schema_property_groups", + related_query_name="schema_property_group", + ) + project = models.ForeignKey("Project", on_delete=models.CASCADE, null=True) + name = models.CharField(max_length=400) + description = models.TextField(blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_schema_property_groups", + ) + + class Meta: + indexes = [ + models.Index(fields=["team", "name"], name="schema_pg_team_name_idx"), + ] + constraints = [ + models.UniqueConstraint( + fields=["team", "name"], + name="unique_schema_property_group_team_name", + ) + ] + + def __str__(self) -> str: + return f"{self.name} / {self.team.name}" + + +class SchemaPropertyGroupProperty(UUIDTModel): + """ + Individual property within a property group. + Defines the expected name, type, and whether it's required. + """ + + property_group = models.ForeignKey( + SchemaPropertyGroup, + on_delete=models.CASCADE, + related_name="properties", + related_query_name="property", + ) + name = models.CharField(max_length=400) + property_type = models.CharField(max_length=50, choices=PropertyType.choices) + is_required = models.BooleanField(default=False) + description = models.TextField(blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + models.Index( + fields=["property_group", "name"], + name="schema_pgp_group_name_idx", + ), + ] + constraints = [ + models.UniqueConstraint( + fields=["property_group", "name"], + name="unique_property_group_property_name", + ), + models.CheckConstraint( + name="property_type_is_valid_schema", + check=models.Q(property_type__in=PropertyType.values), + ), + ] + ordering = ["name"] + + def __str__(self) -> str: + required_str = " (required)" if self.is_required else "" + return f"{self.name} ({self.property_type}){required_str} / {self.property_group.name}" + + +class EventSchema(UUIDTModel): + """ + Associates a property group with an event definition. + Defines which property groups an event should have. + """ + + event_definition = models.ForeignKey( + EventDefinition, + on_delete=models.CASCADE, + related_name="schemas", + related_query_name="schema", + ) + property_group = models.ForeignKey( + SchemaPropertyGroup, + on_delete=models.CASCADE, + related_name="event_schemas", + related_query_name="event_schema", + ) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["event_definition", "property_group"], + name="unique_event_schema", + ) + ] + + def __str__(self) -> str: + return f"{self.event_definition.name} → {self.property_group.name}" diff --git a/products/actions/manifest.tsx b/products/actions/manifest.tsx index 9905350ddc..4225a6d678 100644 --- a/products/actions/manifest.tsx +++ b/products/actions/manifest.tsx @@ -70,7 +70,7 @@ export const manifest: ProductManifest = { treeItemsMetadata: [ { path: 'Actions', - category: 'Definitions', + category: 'Schema', href: urls.actions(), iconType: 'action' as FileSystemIconType, sceneKey: 'Actions', diff --git a/products/revenue_analytics/manifest.tsx b/products/revenue_analytics/manifest.tsx index 2e88fe7318..050a919fda 100644 --- a/products/revenue_analytics/manifest.tsx +++ b/products/revenue_analytics/manifest.tsx @@ -42,8 +42,8 @@ export const manifest: ProductManifest = { }, treeItemsMetadata: [ { - path: 'Revenue settings', - category: 'Definitions', + path: 'Revenue definitions', + category: 'Schema', iconType: 'revenue_analytics_metadata' as FileSystemIconType, href: urls.revenueSettings(), sceneKey: 'RevenueAnalytics',