diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index f15e50479ccdf..80eefe5462b0a 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -528,20 +528,6 @@ }, "EmptyPropertyFilter": { "additionalProperties": false, - "properties": { - "key": { - "not": {} - }, - "operator": { - "not": {} - }, - "type": { - "not": {} - }, - "value": { - "not": {} - } - }, "type": "object" }, "EntityType": { @@ -860,15 +846,7 @@ "enum": ["second", "minute", "hour", "day", "week", "month"], "type": "string" }, - "FunnelLayout": { - "enum": ["horizontal", "vertical"], - "type": "string" - }, - "FunnelPathType": { - "enum": ["funnel_path_before_step", "funnel_path_between_steps", "funnel_path_after_step"], - "type": "string" - }, - "FunnelStepRangeEntityFilter": { + "FunnelExclusion": { "additionalProperties": false, "properties": { "custom_name": { @@ -898,6 +876,14 @@ }, "type": "object" }, + "FunnelLayout": { + "enum": ["horizontal", "vertical"], + "type": "string" + }, + "FunnelPathType": { + "enum": ["funnel_path_before_step", "funnel_path_between_steps", "funnel_path_after_step"], + "type": "string" + }, "FunnelStepReference": { "enum": ["total", "previous"], "type": "string" @@ -927,7 +913,7 @@ }, "exclusions": { "items": { - "$ref": "#/definitions/FunnelStepRangeEntityFilter" + "$ref": "#/definitions/FunnelExclusion" }, "type": "array" }, diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index aa6e30283a2f5..7461e85ec75fa 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -195,6 +195,7 @@ export interface ActionsNode extends EntityNode { kind: NodeKind.ActionsNode id: number } + export interface QueryTiming { /** Key. Shortened to 'k' to save on data. */ k: string diff --git a/frontend/src/scenes/funnels/funnelDataLogic.ts b/frontend/src/scenes/funnels/funnelDataLogic.ts index bedb0d0172e58..078fba4da275a 100644 --- a/frontend/src/scenes/funnels/funnelDataLogic.ts +++ b/frontend/src/scenes/funnels/funnelDataLogic.ts @@ -4,7 +4,7 @@ import { FunnelResultType, FunnelVizType, FunnelStep, - FunnelStepRangeEntityFilter, + FunnelExclusion, FunnelStepReference, FunnelStepWithNestedBreakdown, InsightLogicProps, @@ -381,7 +381,7 @@ export const funnelDataLogic = kea([ // Exclusion filters exclusionDefaultStepRange: [ (s) => [s.querySource], - (querySource: FunnelsQuery): Omit => ({ + (querySource: FunnelsQuery): Omit => ({ funnel_from_step: 0, funnel_to_step: (querySource.series || []).length > 1 ? querySource.series.length - 1 : 1, }), diff --git a/frontend/src/scenes/funnels/funnelUtils.test.ts b/frontend/src/scenes/funnels/funnelUtils.test.ts index 16b92f99941aa..fac6a2b82f0cd 100644 --- a/frontend/src/scenes/funnels/funnelUtils.test.ts +++ b/frontend/src/scenes/funnels/funnelUtils.test.ts @@ -13,7 +13,7 @@ import { FunnelCorrelation, FunnelCorrelationResultsType, FunnelCorrelationType, - FunnelStepRangeEntityFilter, + FunnelExclusion, } from '~/types' import { dayjs } from 'lib/dayjs' @@ -175,7 +175,7 @@ describe('getClampedStepRangeFilter', () => { const stepRange = { funnel_from_step: 0, funnel_to_step: 1, - } as FunnelStepRangeEntityFilter + } as FunnelExclusion const filters = { funnel_from_step: 1, funnel_to_step: 2, @@ -193,7 +193,7 @@ describe('getClampedStepRangeFilter', () => { }) it('ensures step range is clamped to step range', () => { - const stepRange = {} as FunnelStepRangeEntityFilter + const stepRange = {} as FunnelExclusion const filters = { funnel_from_step: -1, funnel_to_step: 12, @@ -211,7 +211,7 @@ describe('getClampedStepRangeFilter', () => { }) it('returns undefined if the incoming filters are undefined', () => { - const stepRange = {} as FunnelStepRangeEntityFilter + const stepRange = {} as FunnelExclusion const filters = { funnel_from_step: undefined, funnel_to_step: undefined, diff --git a/frontend/src/scenes/funnels/funnelUtils.ts b/frontend/src/scenes/funnels/funnelUtils.ts index 46f50052b226d..8dfc6a0539e73 100644 --- a/frontend/src/scenes/funnels/funnelUtils.ts +++ b/frontend/src/scenes/funnels/funnelUtils.ts @@ -1,6 +1,6 @@ import { autoCaptureEventToDescription, clamp } from 'lib/utils' import { - FunnelStepRangeEntityFilter, + FunnelExclusion, FunnelStep, FunnelStepWithNestedBreakdown, BreakdownKeyType, @@ -225,9 +225,7 @@ export const isStepsEmpty = (filters: FunnelsFilterType): boolean => export const isStepsUndefined = (filters: FunnelsFilterType): boolean => typeof filters.events === 'undefined' && (typeof filters.actions === 'undefined' || filters.actions.length === 0) -export const deepCleanFunnelExclusionEvents = ( - filters: FunnelsFilterType -): FunnelStepRangeEntityFilter[] | undefined => { +export const deepCleanFunnelExclusionEvents = (filters: FunnelsFilterType): FunnelExclusion[] | undefined => { if (!filters.exclusions) { return undefined } @@ -255,9 +253,9 @@ export const getClampedStepRangeFilter = ({ stepRange, filters, }: { - stepRange?: FunnelStepRangeEntityFilter + stepRange?: FunnelExclusion filters: FunnelsFilterType -}): FunnelStepRangeEntityFilter => { +}): FunnelExclusion => { const maxStepIndex = Math.max((filters.events?.length || 0) + (filters.actions?.length || 0) - 1, 1) let funnel_from_step = findFirstNumber([stepRange?.funnel_from_step, filters.funnel_from_step]) @@ -282,9 +280,9 @@ export const getClampedStepRangeFilterDataExploration = ({ stepRange, query, }: { - stepRange?: FunnelStepRangeEntityFilter + stepRange?: FunnelExclusion query: FunnelsQuery -}): FunnelStepRangeEntityFilter => { +}): FunnelExclusion => { const maxStepIndex = Math.max(query.series.length || 0 - 1, 1) let funnel_from_step = findFirstNumber([stepRange?.funnel_from_step, query.funnelsFilter?.funnel_from_step]) diff --git a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx index ea099b6ec9764..adcbb55787bb9 100644 --- a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx +++ b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx @@ -3,13 +3,7 @@ import React, { useEffect } from 'react' import { BindLogic, useActions, useValues } from 'kea' import { entityFilterLogic, toFilters, LocalFilter } from './entityFilterLogic' import { ActionFilterRow, MathAvailability } from './ActionFilterRow/ActionFilterRow' -import { - ActionFilter as ActionFilterType, - FilterType, - FunnelStepRangeEntityFilter, - InsightType, - Optional, -} from '~/types' +import { ActionFilter as ActionFilterType, FilterType, FunnelExclusion, InsightType, Optional } from '~/types' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { RenameModal } from 'scenes/insights/filters/ActionFilter/RenameModal' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' @@ -55,11 +49,7 @@ export interface ActionFilterProps { customRowSuffix?: | string | JSX.Element - | ((props: { - filter: ActionFilterType | FunnelStepRangeEntityFilter - index: number - onClose: () => void - }) => JSX.Element) + | ((props: { filter: ActionFilterType | FunnelExclusion; index: number; onClose: () => void }) => JSX.Element) /** Show nested arrows to the left of property filter buttons */ showNestedArrow?: boolean /** Which tabs to show for actions selector */ diff --git a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx index 90e01a83d6df5..921f18b586f22 100644 --- a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx +++ b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx @@ -5,7 +5,7 @@ import { ActionFilter, EntityType, EntityTypes, - FunnelStepRangeEntityFilter, + FunnelExclusion, PropertyFilterValue, BaseMathType, PropertyMathType, @@ -89,11 +89,7 @@ export interface ActionFilterRowProps { customRowSuffix?: | string | JSX.Element - | ((props: { - filter: ActionFilterType | FunnelStepRangeEntityFilter - index: number - onClose: () => void - }) => JSX.Element) // Custom suffix element to show in each row + | ((props: { filter: ActionFilterType | FunnelExclusion; index: number; onClose: () => void }) => JSX.Element) // Custom suffix element to show in each row hasBreakdown: boolean // Whether the current graph has a breakdown filter applied showNestedArrow?: boolean // Show nested arrows to the left of property filter buttons actionsTaxonomicGroupTypes?: TaxonomicFilterGroupType[] // Which tabs to show for actions selector diff --git a/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/ExclusionRowSuffix.tsx b/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/ExclusionRowSuffix.tsx index 3c657491b1134..fbb1f61619023 100644 --- a/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/ExclusionRowSuffix.tsx +++ b/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/ExclusionRowSuffix.tsx @@ -1,7 +1,7 @@ import { Row, Select } from 'antd' import { useActions, useValues } from 'kea' import { ANTD_TOOLTIP_PLACEMENTS } from 'lib/utils' -import { FunnelStepRangeEntityFilter, ActionFilter as ActionFilterType, FunnelsFilterType } from '~/types' +import { FunnelExclusion, ActionFilter as ActionFilterType, FunnelsFilterType } from '~/types' import { insightLogic } from 'scenes/insights/insightLogic' import { LemonButton } from '@posthog/lemon-ui' import { IconDelete } from 'lib/lemon-ui/icons' @@ -10,7 +10,7 @@ import { FunnelsQuery } from '~/queries/schema' import { getClampedStepRangeFilterDataExploration } from 'scenes/funnels/funnelUtils' type ExclusionRowSuffixComponentBaseProps = { - filter: ActionFilterType | FunnelStepRangeEntityFilter + filter: ActionFilterType | FunnelExclusion index: number onClose?: () => void isVertical: boolean @@ -28,7 +28,7 @@ export function ExclusionRowSuffix({ ) const { updateInsightFilter } = useActions(funnelDataLogic(insightProps)) - const setOneEventExclusionFilter = (eventFilter: FunnelStepRangeEntityFilter, index: number): void => { + const setOneEventExclusionFilter = (eventFilter: FunnelExclusion, index: number): void => { const exclusions = ((insightFilter as FunnelsFilterType)?.exclusions || []).map((e, e_i) => e_i === index ? getClampedStepRangeFilterDataExploration({ diff --git a/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/FunnelExclusionsFilter.tsx b/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/FunnelExclusionsFilter.tsx index 9bb147c049967..97e93ecf02702 100644 --- a/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/FunnelExclusionsFilter.tsx +++ b/frontend/src/scenes/insights/filters/FunnelExclusionsFilter/FunnelExclusionsFilter.tsx @@ -3,7 +3,7 @@ import { useActions, useValues } from 'kea' import useSize from '@react-hook/size' import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' -import { FunnelStepRangeEntityFilter, EntityTypes, FilterType } from '~/types' +import { FunnelExclusion, EntityTypes, FilterType } from '~/types' import { insightLogic } from 'scenes/insights/insightLogic' import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic' @@ -22,7 +22,7 @@ export function FunnelExclusionsFilter(): JSX.Element { const isVerticalLayout = !!width && width < 450 // If filter container shrinks below 500px, initiate verticality const setFilters = (filters: Partial): void => { - const exclusions = (filters.events as FunnelStepRangeEntityFilter[]).map((e) => ({ + const exclusions = (filters.events as FunnelExclusion[]).map((e) => ({ ...e, funnel_from_step: e.funnel_from_step || exclusionDefaultStepRange.funnel_from_step, funnel_to_step: e.funnel_to_step || exclusionDefaultStepRange.funnel_to_step, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0d529adc829cd..275561203e954 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -586,10 +586,10 @@ export interface HogQLPropertyFilter extends BasePropertyFilter { } export interface EmptyPropertyFilter { - type?: undefined - value?: undefined - operator?: undefined - key?: undefined + type?: never + value?: never + operator?: never + key?: never } export type AnyPropertyFilter = @@ -786,8 +786,7 @@ export type EntityFilter = { order?: number } -// TODO: Separate FunnelStepRange and FunnelStepRangeEntity filter types -export interface FunnelStepRangeEntityFilter extends Partial { +export interface FunnelExclusion extends Partial { funnel_from_step?: number funnel_to_step?: number } @@ -1682,6 +1681,7 @@ export interface TrendsFilterType extends FilterType { show_percent_stack_view?: boolean breakdown_histogram_bin_count?: number // trends breakdown histogram bin count } + export interface StickinessFilterType extends FilterType { compare?: boolean show_legend?: boolean // used to show/hide legend next to insights graph @@ -1691,6 +1691,7 @@ export interface StickinessFilterType extends FilterType { display?: ChartDisplayType show_values_on_series?: boolean } + export interface FunnelsFilterType extends FilterType { funnel_viz_type?: FunnelVizType // parameter sent to funnels API for time conversion code path funnel_from_step?: number // used in time to convert: initial step index to compute time to convert @@ -1703,7 +1704,7 @@ export interface FunnelsFilterType extends FilterType { funnel_window_interval_unit?: FunnelConversionWindowTimeUnit // minutes, days, weeks, etc. for conversion window funnel_window_interval?: number | undefined // length of conversion window funnel_order_type?: StepOrderValue - exclusions?: FunnelStepRangeEntityFilter[] // used in funnel exclusion filters + exclusions?: FunnelExclusion[] // used in funnel exclusion filters funnel_correlation_person_entity?: Record // Funnel Correlation Persons Filter funnel_correlation_person_converted?: 'true' | 'false' // Funnel Correlation Persons Converted - success or failure counts funnel_custom_steps?: number[] // used to provide custom steps for which to get people in a funnel - primarily for correlation use diff --git a/playwright/e2e-vrt/layout/Navigation.spec.ts b/playwright/e2e-vrt/layout/Navigation.spec.ts index 5c0258c33e694..2af80117af5ad 100644 --- a/playwright/e2e-vrt/layout/Navigation.spec.ts +++ b/playwright/e2e-vrt/layout/Navigation.spec.ts @@ -6,12 +6,14 @@ test.describe('Navigation', () => { test('App Page With Side Bar Hidden (Mobile)', async ({ storyPage }) => { await storyPage.resizeToMobile() await storyPage.goto(toId('Layout/Navigation', 'App Page With Side Bar Hidden')) + await storyPage.mainAppContent.waitFor() await storyPage.expectFullPageScreenshot() }) test('App Page With Side Bar Shown (Mobile)', async ({ storyPage }) => { await storyPage.resizeToMobile() await storyPage.goto(toId('Layout/Navigation', 'App Page With Side Bar Shown')) + await storyPage.mainAppContent.waitFor() await storyPage.expectFullPageScreenshot() }) }) diff --git a/playwright/e2e-vrt/layout/Navigation.spec.ts-snapshots/Navigation-App-Page-With-Side-Bar-Hidden-Mobile-1-chromium-linux.png b/playwright/e2e-vrt/layout/Navigation.spec.ts-snapshots/Navigation-App-Page-With-Side-Bar-Hidden-Mobile-1-chromium-linux.png index 0e709cd227beb..1be473f7dba29 100644 Binary files a/playwright/e2e-vrt/layout/Navigation.spec.ts-snapshots/Navigation-App-Page-With-Side-Bar-Hidden-Mobile-1-chromium-linux.png and b/playwright/e2e-vrt/layout/Navigation.spec.ts-snapshots/Navigation-App-Page-With-Side-Bar-Hidden-Mobile-1-chromium-linux.png differ diff --git a/playwright/e2e-vrt/layout/Navigation.spec.ts-snapshots/Navigation-App-Page-With-Side-Bar-Shown-Mobile-1-chromium-linux.png b/playwright/e2e-vrt/layout/Navigation.spec.ts-snapshots/Navigation-App-Page-With-Side-Bar-Shown-Mobile-1-chromium-linux.png index 2ce1d7971c1e1..ed5bf402c776b 100644 Binary files a/playwright/e2e-vrt/layout/Navigation.spec.ts-snapshots/Navigation-App-Page-With-Side-Bar-Shown-Mobile-1-chromium-linux.png and b/playwright/e2e-vrt/layout/Navigation.spec.ts-snapshots/Navigation-App-Page-With-Side-Bar-Shown-Mobile-1-chromium-linux.png differ diff --git a/posthog/hogql_queries/legacy_compatibility/filter_to_query.py b/posthog/hogql_queries/legacy_compatibility/filter_to_query.py new file mode 100644 index 0000000000000..3602dbcc538cd --- /dev/null +++ b/posthog/hogql_queries/legacy_compatibility/filter_to_query.py @@ -0,0 +1,269 @@ +from posthog.models.entity.entity import Entity as BackendEntity +from posthog.models.filters import AnyInsightFilter +from posthog.models.filters.filter import Filter as LegacyFilter +from posthog.models.filters.path_filter import PathFilter as LegacyPathFilter +from posthog.models.filters.retention_filter import RetentionFilter as LegacyRetentionFilter +from posthog.models.filters.stickiness_filter import StickinessFilter as LegacyStickinessFilter +from posthog.schema import ( + ActionsNode, + BreakdownFilter, + DateRange, + EventsNode, + FunnelExclusion, + FunnelsFilter, + FunnelsQuery, + LifecycleFilter, + LifecycleQuery, + PathsFilter, + PathsQuery, + PropertyGroupFilter, + RetentionFilter, + RetentionQuery, + StickinessFilter, + StickinessQuery, + TrendsFilter, + TrendsQuery, +) +from posthog.types import InsightQueryNode + + +def entity_to_node(entity: BackendEntity) -> EventsNode | ActionsNode: + shared = { + "name": entity.name, + "custom_name": entity.custom_name, + "properties": entity._data.get("properties", None), + "math": entity.math, + "math_property": entity.math_property, + "math_hogql": entity.math_hogql, + "math_group_type_index": entity.math_group_type_index, + } + + if entity.type == "actions": + return ActionsNode(id=entity.id, **shared) + else: + return EventsNode(event=entity.id, **shared) + + +def to_base_entity_dict(entity: BackendEntity): + return { + "type": entity.type, + "id": entity.id, + "name": entity.name, + "custom_name": entity.custom_name, + "order": entity.order, + } + + +insight_to_query_type = { + "TRENDS": TrendsQuery, + "FUNNELS": FunnelsQuery, + "RETENTION": RetentionQuery, + "PATHS": PathsQuery, + "LIFECYCLE": LifecycleQuery, + "STICKINESS": StickinessQuery, +} + + +def _date_range(filter: AnyInsightFilter): + return {"dateRange": DateRange(**filter.date_to_dict())} + + +def _interval(filter: AnyInsightFilter): + if filter.insight == "RETENTION" or filter.insight == "PATHS": + return {} + return {"interval": filter.interval} + + +def _series(filter: AnyInsightFilter): + if filter.insight == "RETENTION" or filter.insight == "PATHS": + return {} + return {"series": map(entity_to_node, filter.entities)} + + +def _sampling_factor(filter: AnyInsightFilter): + return {"samplingFactor": filter.sampling_factor} + + +def _filter_test_accounts(filter: AnyInsightFilter): + return {"filterTestAccounts": filter.filter_test_accounts} + + +def _properties(filter: AnyInsightFilter): + raw_properties = filter._data.get("properties", None) + if raw_properties is None or len(raw_properties) == 0: + return {} + elif isinstance(raw_properties, list): + raw_properties = {"type": "AND", "values": [{"type": "AND", "values": raw_properties}]} + return {"properties": PropertyGroupFilter(**raw_properties)} + else: + return {"properties": PropertyGroupFilter(**raw_properties)} + + +def _breakdown_filter(filter: AnyInsightFilter): + if filter.insight != "TRENDS" and filter.insight != "FUNNELS": + return {} + + breakdownFilter = { + "breakdown_type": filter.breakdown_type, + "breakdown": filter.breakdown, + "breakdown_normalize_url": filter.breakdown_normalize_url, + "breakdown_group_type_index": filter.breakdown_group_type_index, + "breakdown_histogram_bin_count": filter.breakdown_histogram_bin_count if filter.insight == "TRENDS" else None, + } + + if filter.breakdowns is not None: + if len(filter.breakdowns) == 1: + breakdownFilter["breakdown_type"] = filter.breakdowns[0].get("type", None) + breakdownFilter["breakdown"] = filter.breakdowns[0].get("property", None) + else: + raise Exception("Could not convert multi-breakdown property `breakdowns` - found more than one breakdown") + + if breakdownFilter["breakdown"] is not None and breakdownFilter["breakdown_type"] is None: + breakdownFilter["breakdown_type"] = "event" + + return {"breakdown": BreakdownFilter(**breakdownFilter)} + + +def _group_aggregation_filter(filter: AnyInsightFilter): + if isinstance(filter, LegacyStickinessFilter): + return {} + return {"aggregation_group_type_index": filter.aggregation_group_type_index} + + +def _insight_filter(filter: AnyInsightFilter): + if filter.insight == "TRENDS" and isinstance(filter, LegacyFilter): + return { + "trendsFilter": TrendsFilter( + smoothing_intervals=filter.smoothing_intervals, + # show_legend=filter.show_legend, + # hidden_legend_indexes=cleanHiddenLegendIndexes(filter.hidden_legend_keys), + compare=filter.compare, + aggregation_axis_format=filter.aggregation_axis_format, + aggregation_axis_prefix=filter.aggregation_axis_prefix, + aggregation_axis_postfix=filter.aggregation_axis_postfix, + formula=filter.formula, + shown_as=filter.shown_as, + display=filter.display, + # show_values_on_series=filter.show_values_on_series, + # show_percent_stack_view=filter.show_percent_stack_view, + ) + } + elif filter.insight == "FUNNELS" and isinstance(filter, LegacyFilter): + return { + "funnelsFilter": FunnelsFilter( + funnel_viz_type=filter.funnel_viz_type, + funnel_order_type=filter.funnel_order_type, + funnel_from_step=filter.funnel_from_step, + funnel_to_step=filter.funnel_to_step, + funnel_window_interval_unit=filter.funnel_window_interval_unit, + funnel_window_interval=filter.funnel_window_interval, + # funnel_step_reference=filter.funnel_step_reference, + # funnel_step_breakdown=filter.funnel_step_breakdown, + breakdown_attribution_type=filter.breakdown_attribution_type, + breakdown_attribution_value=filter.breakdown_attribution_value, + bin_count=filter.bin_count, + exclusions=[ + FunnelExclusion( + **to_base_entity_dict(entity), + funnel_from_step=entity.funnel_from_step, + funnel_to_step=entity.funnel_to_step, + ) + for entity in filter.exclusions + ], + funnel_custom_steps=filter.funnel_custom_steps, + # funnel_advanced=filter.funnel_advanced, + layout=filter.layout, + funnel_step=filter.funnel_step, + entrance_period_start=filter.entrance_period_start, + drop_off=filter.drop_off, + # hidden_legend_breakdowns: cleanHiddenLegendSeries(filters.hidden_legend_keys), + funnel_aggregate_by_hogql=filter.funnel_aggregate_by_hogql, + # funnel_correlation_person_entity=filter.funnel_correlation_person_entity, + # funnel_correlation_person_converted=filter.funnel_correlation_person_converted, + ), + } + elif filter.insight == "RETENTION" and isinstance(filter, LegacyRetentionFilter): + return { + "retentionFilter": RetentionFilter( + retention_type=filter.retention_type, + # retention_reference=filter.retention_reference, + total_intervals=filter.total_intervals, + returning_entity=to_base_entity_dict(filter.returning_entity), + target_entity=to_base_entity_dict(filter.target_entity), + period=filter.period, + ) + } + elif filter.insight == "PATHS" and isinstance(filter, LegacyPathFilter): + return { + "pathsFilter": PathsFilter( + # path_type=filter.path_type, # legacy + paths_hogql_expression=filter.paths_hogql_expression, + include_event_types=filter._data.get("include_event_types"), + start_point=filter.start_point, + end_point=filter.end_point, + path_groupings=filter.path_groupings, + exclude_events=filter.exclude_events, + step_limit=filter.step_limit, + path_replacements=filter.path_replacements, + local_path_cleaning_filters=filter.local_path_cleaning_filters, + edge_limit=filter.edge_limit, + min_edge_weight=filter.min_edge_weight, + max_edge_weight=filter.max_edge_weight, + funnel_paths=filter.funnel_paths, + funnel_filter=filter._data.get("funnel_filter"), + ) + } + elif filter.insight == "LIFECYCLE": + return { + "lifecycleFilter": LifecycleFilter( + shown_as=filter.shown_as, + # toggledLifecycles=filter.toggledLifecycles, + # show_values_on_series=filter.show_values_on_series, + ) + } + elif filter.insight == "STICKINESS" and isinstance(filter, LegacyStickinessFilter): + return { + "stickinessFilter": StickinessFilter( + compare=filter.compare, + shown_as=filter.shown_as, + # show_legend=filter.show_legend, + # hidden_legend_indexes: cleanHiddenLegendIndexes(filters.hidden_legend_keys), + # show_values_on_series=filter.show_values_on_series, + ) + } + else: + raise Exception(f"Invalid insight type {filter.insight}.") + + +def filter_to_query(filter: AnyInsightFilter) -> InsightQueryNode: + if (filter.insight == "TRENDS" or filter.insight == "FUNNELS" or filter.insight == "LIFECYCLE") and isinstance( + filter, LegacyFilter + ): + matching_filter_type = True + elif filter.insight == "RETENTION" and isinstance(filter, LegacyRetentionFilter): + matching_filter_type = True + elif filter.insight == "PATHS" and isinstance(filter, LegacyPathFilter): + matching_filter_type = True + elif filter.insight == "STICKINESS" and isinstance(filter, LegacyStickinessFilter): + matching_filter_type = True + else: + matching_filter_type = False + + if not matching_filter_type: + raise Exception(f"Filter type {type(filter)} does not match insight type {filter.insight}") + + Query = insight_to_query_type[filter.insight] + + data = { + **_date_range(filter), + **_interval(filter), + **_series(filter), + **_sampling_factor(filter), + **_filter_test_accounts(filter), + **_properties(filter), + **_breakdown_filter(filter), + **_group_aggregation_filter(filter), + **_insight_filter(filter), + } + + return Query(**data) diff --git a/posthog/hogql_queries/legacy_compatibility/test/test_filter_to_query.py b/posthog/hogql_queries/legacy_compatibility/test/test_filter_to_query.py new file mode 100644 index 0000000000000..6f1fe48d02c8a --- /dev/null +++ b/posthog/hogql_queries/legacy_compatibility/test/test_filter_to_query.py @@ -0,0 +1,1008 @@ +import pytest +from posthog.hogql_queries.legacy_compatibility.filter_to_query import filter_to_query +from posthog.models.filters.filter import Filter as LegacyFilter +from posthog.models.filters.path_filter import PathFilter as LegacyPathFilter +from posthog.models.filters.retention_filter import RetentionFilter as LegacyRetentionFilter +from posthog.models.filters.stickiness_filter import StickinessFilter as LegacyStickinessFilter +from posthog.schema import ( + ActionsNode, + AggregationAxisFormat, + BaseMathType, + BreakdownAttributionType, + BreakdownFilter, + BreakdownType, + ChartDisplayType, + CohortPropertyFilter, + CountPerActorMathType, + ElementPropertyFilter, + EntityType, + EventPropertyFilter, + EventsNode, + FunnelConversionWindowTimeUnit, + FunnelExclusion, + FunnelPathType, + FunnelVizType, + GroupPropertyFilter, + HogQLPropertyFilter, + Key, + PathCleaningFilter, + PathType, + PersonPropertyFilter, + PropertyMathType, + PropertyOperator, + RetentionPeriod, + RetentionType, + SessionPropertyFilter, + ShownAsValue, + StepOrderValue, + TrendsFilter, + FunnelsFilter, + RetentionFilter, + PathsFilter, + StickinessFilter, + LifecycleFilter, +) +from posthog.test.base import BaseTest + + +insight_0 = { + "events": [{"id": "signed_up", "type": "events", "order": 0}], + "actions": [], + "display": "ActionsLineGraph", + "insight": "TRENDS", + "interval": "week", + "date_from": "-8w", +} +insight_1 = { + "events": [{"id": "signed_up", "type": "events", "order": 0}], + "actions": [], + "display": "WorldMap", + "insight": "TRENDS", + "breakdown": "$geoip_country_code", + "date_from": "-1m", + "breakdown_type": "event", +} +insight_2 = { + "events": [ + {"id": "signed_up", "name": "signed_up", "type": "events", "order": 2, "custom_name": "Signed up"}, + {"id": "upgraded_plan", "name": "upgraded_plan", "type": "events", "order": 4, "custom_name": "Upgraded plan"}, + ], + "actions": [{"id": 1, "name": "Interacted with file", "type": "actions", "order": 3}], + "display": "FunnelViz", + "insight": "FUNNELS", + "interval": "day", + "date_from": "-1m", + "funnel_viz_type": "steps", + "filter_test_accounts": True, +} +insight_3 = { + "period": "Week", + "display": "ActionsTable", + "insight": "RETENTION", + "properties": { + "type": "AND", + "values": [ + {"type": "AND", "values": [{"key": "email", "type": "person", "value": "is_set", "operator": "is_set"}]} + ], + }, + "target_entity": {"id": "signed_up", "name": "signed_up", "type": "events", "order": 0}, + "retention_type": "retention_first_time", + "total_intervals": 9, + "returning_entity": {"id": 1, "name": "Interacted with file", "type": "actions", "order": 0}, +} +insight_4 = { + "events": [], + "actions": [{"id": 1, "math": "total", "name": "Interacted with file", "type": "actions", "order": 0}], + "compare": False, + "display": "ActionsLineGraph", + "insight": "LIFECYCLE", + "interval": "day", + "shown_as": "Lifecycle", + "date_from": "-8w", + "new_entity": [], + "properties": [], + "filter_test_accounts": True, +} +insight_5 = { + "events": [ + { + "id": "uploaded_file", + "math": "sum", + "name": "uploaded_file", + "type": "events", + "order": 0, + "custom_name": "Uploaded bytes", + "math_property": "file_size_b", + }, + { + "id": "deleted_file", + "math": "sum", + "name": "deleted_file", + "type": "events", + "order": 1, + "custom_name": "Deleted bytes", + "math_property": "file_size_b", + }, + ], + "actions": [], + "display": "ActionsLineGraph", + "insight": "TRENDS", + "interval": "week", + "date_from": "-8w", + "new_entity": [], + "properties": [], + "filter_test_accounts": True, +} +insight_6 = { + "events": [{"id": "paid_bill", "math": "sum", "type": "events", "order": 0, "math_property": "amount_usd"}], + "actions": [], + "display": "ActionsLineGraph", + "insight": "TRENDS", + "interval": "month", + "date_from": "-6m", +} +insight_7 = { + "events": [ + { + "id": "paid_bill", + "math": "unique_group", + "name": "paid_bill", + "type": "events", + "order": 0, + "math_group_type_index": 0, + } + ], + "actions": [], + "compare": True, + "date_to": None, + "display": "BoldNumber", + "insight": "TRENDS", + "interval": "day", + "date_from": "-30d", + "properties": [], + "filter_test_accounts": True, +} +insight_8 = { + "events": [{"id": "$pageview", "math": "total", "type": "events", "order": 0}], + "actions": [], + "display": "ActionsTable", + "insight": "TRENDS", + "interval": "day", + "breakdown": "$current_url", + "date_from": "-6m", + "new_entity": [], + "properties": { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [{"key": "$current_url", "type": "event", "value": "/files/", "operator": "not_icontains"}], + } + ], + }, + "breakdown_type": "event", +} +insight_9 = { + "events": [ + { + "id": "$pageview", + "name": "$pageview", + "type": "events", + "order": 0, + "properties": [ + {"key": "$current_url", "type": "event", "value": "https://hedgebox.net/", "operator": "exact"} + ], + "custom_name": "Viewed homepage", + }, + { + "id": "$pageview", + "name": "$pageview", + "type": "events", + "order": 1, + "properties": [ + {"key": "$current_url", "type": "event", "value": "https://hedgebox.net/signup/", "operator": "regex"} + ], + "custom_name": "Viewed signup page", + }, + {"id": "signed_up", "name": "signed_up", "type": "events", "order": 2, "custom_name": "Signed up"}, + ], + "actions": [], + "display": "FunnelViz", + "insight": "FUNNELS", + "interval": "day", + "date_from": "-1m", + "funnel_viz_type": "steps", + "filter_test_accounts": True, +} +insight_10 = { + "date_to": None, + "insight": "PATHS", + "date_from": "-30d", + "edge_limit": 50, + "properties": {"type": "AND", "values": []}, + "step_limit": 5, + "start_point": "https://hedgebox.net/", + "funnel_filter": {}, + "exclude_events": [], + "path_groupings": ["/files/*"], + "include_event_types": ["$pageview"], + "local_path_cleaning_filters": [], +} +insight_11 = { + "events": [ + {"id": "uploaded_file", "type": "events", "order": 0}, + {"id": "deleted_file", "type": "events", "order": 2}, + {"id": "downloaded_file", "type": "events", "order": 1}, + ], + "actions": [], + "display": "ActionsLineGraph", + "insight": "TRENDS", + "interval": "day", + "date_from": "-30d", +} +insight_12 = { + "events": [{"id": "$pageview", "math": "dau", "type": "events"}], + "display": "ActionsLineGraph", + "insight": "TRENDS", + "interval": "day", + "date_from": "-30d", + "filter_test_accounts": True, +} +insight_13 = { + "events": [{"id": "$pageview", "math": "dau", "type": "events"}], + "display": "ActionsLineGraph", + "insight": "TRENDS", + "interval": "week", + "date_from": "-90d", + "filter_test_accounts": True, +} +insight_14 = { + "period": "Week", + "insight": "RETENTION", + "target_entity": {"id": "$pageview", "type": "events"}, + "retention_type": "retention_first_time", + "returning_entity": {"id": "$pageview", "type": "events"}, + "filter_test_accounts": True, +} +insight_15 = { + "events": [{"id": "$pageview", "type": "events"}], + "insight": "LIFECYCLE", + "interval": "week", + "shown_as": "Lifecycle", + "date_from": "-30d", + "entity_type": "events", + "filter_test_accounts": True, +} +insight_16 = { + "events": [{"id": "$pageview", "math": "dau", "type": "events"}], + "display": "ActionsBarValue", + "insight": "TRENDS", + "interval": "day", + "breakdown": "$referring_domain", + "date_from": "-14d", + "breakdown_type": "event", + "filter_test_accounts": True, +} +insight_17 = { + "events": [ + {"id": "$pageview", "type": "events", "order": 0, "custom_name": "First page view"}, + {"id": "$pageview", "type": "events", "order": 1, "custom_name": "Second page view"}, + {"id": "$pageview", "type": "events", "order": 2, "custom_name": "Third page view"}, + ], + "layout": "horizontal", + "display": "FunnelViz", + "insight": "FUNNELS", + "interval": "day", + "breakdown": "$browser", + "exclusions": [], + "breakdown_type": "event", + "funnel_viz_type": "steps", + "filter_test_accounts": True, +} + +test_insights = [ + insight_0, + insight_1, + insight_2, + insight_3, + insight_4, + insight_5, + insight_6, + insight_7, + insight_8, + insight_9, + insight_10, + insight_11, + insight_12, + insight_13, + insight_14, + insight_15, + insight_16, + insight_17, +] + + +@pytest.mark.parametrize("insight", test_insights) +def test_base_insights(insight): + """smoke test (i.e. filter_to_query should not throw) for real world insights""" + if insight.get("insight") == "RETENTION": + filter = LegacyRetentionFilter(data=insight) + elif insight.get("insight") == "PATHS": + filter = LegacyPathFilter(data=insight) + elif insight.get("insight") == "STICKINESS": + filter = LegacyStickinessFilter(data=insight) + else: + filter = LegacyFilter(data=insight) + filter_to_query(filter) + + +properties_0 = [] +properties_1 = [{"key": "account_id", "type": "event", "value": ["some_id"], "operator": "exact"}] +properties_2 = [ + {"key": "account_id", "type": "event", "value": ["some_id"], "operator": "exact"}, + {"key": "$current_url", "type": "event", "value": "/path", "operator": "not_icontains"}, +] +properties_3 = {} +properties_4 = {"type": "AND", "values": []} +properties_5 = {"type": "AND", "values": [{"type": "AND", "values": []}]} +properties_6 = { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [ + {"key": "$current_url", "type": "event", "value": "?", "operator": "not_icontains"}, + {"key": "$referring_domain", "type": "event", "value": "google", "operator": "icontains"}, + ], + } + ], +} +properties_7 = { + "type": "AND", + "values": [ + {"type": "AND", "values": [{"type": "AND", "values": []}, {"type": "AND", "values": []}]}, + { + "type": "AND", + "values": [{"key": "dateDiff('minute', timestamp, now()) < 5", "type": "hogql", "value": None}], + }, + ], +} +properties_8 = { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [{"key": "dateDiff('minute', timestamp, now()) < 5", "type": "hogql", "value": None}], + }, + { + "type": "AND", + "values": [{"key": "dateDiff('minute', timestamp, now()) < 5", "type": "hogql", "value": None}], + }, + ], +} +properties_9 = { + "type": "AND", + "values": [ + { + "type": "AND", + "values": [ + {"key": "$browser", "value": ["Chrome"], "operator": "exact", "type": "event"}, + {"key": "$browser", "value": ["Chrome"], "operator": "exact", "type": "person"}, + {"key": "$feature/hogql-insights", "value": ["true"], "operator": "exact", "type": "event"}, + { + "key": "site_url", + "value": ["http://localhost:8000"], + "operator": "exact", + "type": "group", + "group_type_index": 1, + }, + {"key": "id", "value": 2, "type": "cohort"}, + {"key": "tag_name", "value": ["elem"], "operator": "exact", "type": "element"}, + {"key": "$session_duration", "value": None, "operator": "gt", "type": "session"}, + {"type": "hogql", "key": "properties.name", "value": None}, + ], + }, + {"type": "OR", "values": [{}]}, + ], +} + +test_properties = [ + properties_0, + properties_1, + properties_2, + properties_3, + properties_4, + properties_5, + properties_6, + properties_7, + properties_8, + properties_9, +] + + +@pytest.mark.parametrize("properties", test_properties) +def test_base_properties(properties): + """smoke test (i.e. filter_to_query should not throw) for real world properties""" + filter = LegacyFilter(data={"properties": properties}) + filter_to_query(filter) + + +class TestFilterToQuery(BaseTest): + def test_base_trend(self): + filter = LegacyFilter(data={}) + + query = filter_to_query(filter) + + self.assertEqual(query.kind, "TrendsQuery") + + def test_full_trend(self): + filter = LegacyFilter(data={}) + + query = filter_to_query(filter) + + self.assertEqual( + query.model_dump(exclude_defaults=True), + { + "dateRange": {"date_from": "-7d"}, + "interval": "day", + "series": [], + "filterTestAccounts": False, + "breakdown": {"breakdown_normalize_url": False}, + "trendsFilter": { + "compare": False, + "display": ChartDisplayType.ActionsLineGraph, + "smoothing_intervals": 1, + }, + }, + ) + + def test_base_funnel(self): + filter = LegacyFilter(data={"insight": "FUNNELS"}) + + query = filter_to_query(filter) + + self.assertEqual(query.kind, "FunnelsQuery") + + def test_base_retention_query(self): + filter = LegacyFilter(data={"insight": "RETENTION"}) + + with pytest.raises(Exception) as exception: + filter_to_query(filter) + + self.assertEqual( + str(exception.value), + "Filter type does not match insight type RETENTION", + ) + + def test_base_retention_query_from_retention_filter(self): + filter = LegacyRetentionFilter(data={}) + + query = filter_to_query(filter) + + self.assertEqual(query.kind, "RetentionQuery") + + def test_base_paths_query(self): + filter = LegacyFilter(data={"insight": "PATHS"}) + + with pytest.raises(Exception) as exception: + filter_to_query(filter) + + self.assertEqual( + str(exception.value), + "Filter type does not match insight type PATHS", + ) + + def test_base_path_query_from_path_filter(self): + filter = LegacyPathFilter(data={}) + + query = filter_to_query(filter) + + self.assertEqual(query.kind, "PathsQuery") + + def test_base_lifecycle_query(self): + filter = LegacyFilter(data={"insight": "LIFECYCLE"}) + + query = filter_to_query(filter) + + self.assertEqual(query.kind, "LifecycleQuery") + + def test_base_stickiness_query(self): + filter = LegacyFilter(data={"insight": "STICKINESS"}) + + with pytest.raises(Exception) as exception: + filter_to_query(filter) + + self.assertEqual( + str(exception.value), + "Filter type does not match insight type STICKINESS", + ) + + def test_base_stickiness_query_from_stickiness_filter(self): + filter = LegacyStickinessFilter(data={}, team=self.team) + + query = filter_to_query(filter) + + self.assertEqual(query.kind, "StickinessQuery") + + def test_date_range_default(self): + filter = LegacyFilter(data={}) + + query = filter_to_query(filter) + + self.assertEqual(query.dateRange.date_from, "-7d") + self.assertEqual(query.dateRange.date_to, None) + + def test_date_range_custom(self): + filter = LegacyFilter(data={"date_from": "-14d", "date_to": "-7d"}) + + query = filter_to_query(filter) + + self.assertEqual(query.dateRange.date_from, "-14d") + self.assertEqual(query.dateRange.date_to, "-7d") + + def test_interval_default(self): + filter = LegacyFilter(data={}) + + query = filter_to_query(filter) + + self.assertEqual(query.interval, "day") + + def test_interval_custom(self): + filter = LegacyFilter(data={"interval": "hour"}) + + query = filter_to_query(filter) + + self.assertEqual(query.interval, "hour") + + def test_series_default(self): + filter = LegacyFilter(data={}) + + query = filter_to_query(filter) + + self.assertEqual(query.series, []) + + def test_series_custom(self): + filter = LegacyFilter( + data={ + "events": [{"id": "$pageview"}, {"id": "$pageview", "math": "dau"}], + "actions": [{"id": 1}, {"id": 1, "math": "dau"}], + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.series, + [ + ActionsNode(id=1), + ActionsNode(id=1, math=BaseMathType.dau), + EventsNode(event="$pageview", name="$pageview"), + EventsNode(event="$pageview", name="$pageview", math=BaseMathType.dau), + ], + ) + + def test_series_order(self): + filter = LegacyFilter( + data={ + "events": [{"id": "$pageview", "order": 1}, {"id": "$pageview", "math": "dau", "order": 2}], + "actions": [{"id": 1, "order": 3}, {"id": 1, "math": "dau", "order": 0}], + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.series, + [ + ActionsNode(id=1, math=BaseMathType.dau), + EventsNode(event="$pageview", name="$pageview"), + EventsNode(event="$pageview", name="$pageview", math=BaseMathType.dau), + ActionsNode(id=1), + ], + ) + + def test_series_math(self): + filter = LegacyFilter( + data={ + "events": [ + {"id": "$pageview", "math": "dau"}, # base math type + {"id": "$pageview", "math": "median", "math_property": "$math_prop"}, # property math type + {"id": "$pageview", "math": "avg_count_per_actor"}, # count per actor math type + {"id": "$pageview", "math": "unique_group", "math_group_type_index": 0}, # unique group + { + "id": "$pageview", + "math": "hogql", + "math_hogql": "avg(toInt(properties.$session_id)) + 1000", + }, # hogql + ] + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.series, + [ + EventsNode(event="$pageview", name="$pageview", math=BaseMathType.dau), + EventsNode( + event="$pageview", name="$pageview", math=PropertyMathType.median, math_property="$math_prop" + ), + EventsNode(event="$pageview", name="$pageview", math=CountPerActorMathType.avg_count_per_actor), + EventsNode(event="$pageview", name="$pageview", math="unique_group", math_group_type_index=0), + EventsNode( + event="$pageview", + name="$pageview", + math="hogql", + math_hogql="avg(toInt(properties.$session_id)) + 1000", + ), + ], + ) + + def test_series_properties(self): + filter = LegacyFilter( + data={ + "events": [ + {"id": "$pageview", "properties": []}, # smoke test + { + "id": "$pageview", + "properties": [{"key": "success", "type": "event", "value": ["true"], "operator": "exact"}], + }, + { + "id": "$pageview", + "properties": [{"key": "email", "type": "person", "value": "is_set", "operator": "is_set"}], + }, + { + "id": "$pageview", + "properties": [{"key": "text", "value": ["some text"], "operator": "exact", "type": "element"}], + }, + { + "id": "$pageview", + "properties": [{"key": "$session_duration", "value": 1, "operator": "gt", "type": "session"}], + }, + {"id": "$pageview", "properties": [{"key": "id", "value": 2, "type": "cohort"}]}, + { + "id": "$pageview", + "properties": [ + { + "key": "name", + "value": ["Hedgebox Inc."], + "operator": "exact", + "type": "group", + "group_type_index": 2, + } + ], + }, + { + "id": "$pageview", + "properties": [ + {"key": "dateDiff('minute', timestamp, now()) < 30", "type": "hogql", "value": None} + ], + }, + { + "id": "$pageview", + "properties": [ + {"key": "$referring_domain", "type": "event", "value": "google", "operator": "icontains"}, + {"key": "utm_source", "type": "event", "value": "is_not_set", "operator": "is_not_set"}, + ], + }, + ] + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.series, + [ + EventsNode(event="$pageview", name="$pageview", properties=[]), + EventsNode( + event="$pageview", + name="$pageview", + properties=[EventPropertyFilter(key="success", value=["true"], operator=PropertyOperator.exact)], + ), + EventsNode( + event="$pageview", + name="$pageview", + properties=[PersonPropertyFilter(key="email", value="is_set", operator=PropertyOperator.is_set)], + ), + EventsNode( + event="$pageview", + name="$pageview", + properties=[ + ElementPropertyFilter(key=Key.text, value=["some text"], operator=PropertyOperator.exact) + ], + ), + EventsNode( + event="$pageview", + name="$pageview", + properties=[SessionPropertyFilter(value=1, operator=PropertyOperator.gt)], + ), + EventsNode(event="$pageview", name="$pageview", properties=[CohortPropertyFilter(value=2)]), + EventsNode( + event="$pageview", + name="$pageview", + properties=[ + GroupPropertyFilter( + key="name", value=["Hedgebox Inc."], operator=PropertyOperator.exact, group_type_index=2 + ) + ], + ), + EventsNode( + event="$pageview", + name="$pageview", + properties=[HogQLPropertyFilter(key="dateDiff('minute', timestamp, now()) < 30")], + ), + EventsNode( + event="$pageview", + name="$pageview", + properties=[ + EventPropertyFilter( + key="$referring_domain", value="google", operator=PropertyOperator.icontains + ), + EventPropertyFilter(key="utm_source", value="is_not_set", operator=PropertyOperator.is_not_set), + ], + ), + ], + ) + + def test_breakdown(self): + filter = LegacyFilter(data={"breakdown_type": "event", "breakdown": "$browser"}) + + query = filter_to_query(filter) + + self.assertEqual( + query.breakdown, + BreakdownFilter(breakdown_type=BreakdownType.event, breakdown="$browser", breakdown_normalize_url=False), + ) + + def test_breakdown_converts_multi(self): + filter = LegacyFilter(data={"breakdowns": [{"type": "event", "property": "$browser"}]}) + + query = filter_to_query(filter) + + self.assertEqual( + query.breakdown, + BreakdownFilter(breakdown_type=BreakdownType.event, breakdown="$browser", breakdown_normalize_url=False), + ) + + def test_breakdown_type_default(self): + filter = LegacyFilter(data={"breakdown": "some_prop"}) + + query = filter_to_query(filter) + + self.assertEqual( + query.breakdown, + BreakdownFilter(breakdown_type=BreakdownType.event, breakdown="some_prop", breakdown_normalize_url=False), + ) + + def test_trends_filter(self): + filter = LegacyFilter( + data={ + "smoothing_intervals": 2, + "compare": True, + "aggregation_axis_format": "duration_ms", + "aggregation_axis_prefix": "pre", + "aggregation_axis_postfix": "post", + "formula": "A + B", + "shown_as": "Volume", + "display": "ActionsAreaGraph", + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.trendsFilter, + TrendsFilter( + smoothing_intervals=2, + compare=True, + aggregation_axis_format=AggregationAxisFormat.duration_ms, + aggregation_axis_prefix="pre", + aggregation_axis_postfix="post", + formula="A + B", + shown_as=ShownAsValue.Volume, + display=ChartDisplayType.ActionsAreaGraph, + ), + ) + + def test_funnels_filter(self): + filter = LegacyFilter( + data={ + "insight": "FUNNELS", + "funnel_viz_type": "steps", + "funnel_window_interval_unit": "hour", + "funnel_window_interval": 13, + "breakdown_attribution_type": "step", + "breakdown_attribution_value": 2, + "funnel_order_type": "strict", + "funnel_aggregate_by_hogql": "person_id", + "exclusions": [ + { + "id": "$pageview", + "type": "events", + "order": 0, + "name": "$pageview", + "funnel_from_step": 1, + "funnel_to_step": 2, + } + ], + "bin_count": 15, # used in time to convert: number of bins to show in histogram + "funnel_from_step": 1, # used in time to convert: initial step index to compute time to convert + "funnel_to_step": 2, # used in time to convert: ending step index to compute time to convert + # + # frontend only params + # "layout": layout, + # "funnel_advanced":funnel_advanced, # unused, previously used to toggle advanced options on or off + # "funnel_step_reference": "previous", # whether conversion shown in graph should be across all steps or just from the previous step + # hidden_legend_keys # used to toggle visibilities in table and legend + # + # persons endpoint only params + # "funnel_step_breakdown": funnel_step_breakdown, # used in steps breakdown: persons modal + # "funnel_correlation_person_entity":funnel_correlation_person_entity, + # "funnel_correlation_person_converted":funnel_correlation_person_converted, # success or failure counts + # "entrance_period_start": entrance_period_start, # this and drop_off is used for funnels time conversion date for the persons modal + # "drop_off": drop_off, + # "funnel_step": funnel_step, + # "funnel_custom_steps": funnel_custom_steps, + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.funnelsFilter, + FunnelsFilter( + funnel_viz_type=FunnelVizType.steps, + funnel_from_step=1, + funnel_to_step=2, + funnel_window_interval_unit=FunnelConversionWindowTimeUnit.hour, + funnel_window_interval=13, + breakdown_attribution_type=BreakdownAttributionType.step, + breakdown_attribution_value=2, + funnel_order_type=StepOrderValue.strict, + exclusions=[ + FunnelExclusion( + id="$pageview", + type=EntityType.events, + order=0, + name="$pageview", + funnel_from_step=1, + funnel_to_step=2, + ) + ], + bin_count=15, + funnel_aggregate_by_hogql="person_id", + funnel_custom_steps=[], + # funnel_step_reference=FunnelStepReference.previous, + ), + ) + + def test_retention_filter(self): + filter = LegacyRetentionFilter( + data={ + "retention_type": "retention_first_time", + # retention_reference="previous", + "total_intervals": 12, + "returning_entity": {"id": "$pageview", "name": "$pageview", "type": "events"}, + "target_entity": {"id": "$pageview", "name": "$pageview", "type": "events"}, + "period": "Week", + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.retentionFilter, + RetentionFilter( + retention_type=RetentionType.retention_first_time, + total_intervals=12, + period=RetentionPeriod.Week, + returning_entity={ + "id": "$pageview", + "name": "$pageview", + "type": "events", + "custom_name": None, + "order": None, + }, + target_entity={ + "id": "$pageview", + "name": "$pageview", + "type": "events", + "custom_name": None, + "order": None, + }, + ), + ) + + def test_paths_filter(self): + filter = LegacyPathFilter( + data={ + "include_event_types": ["$pageview", "hogql"], + "start_point": "http://localhost:8000/events", + "end_point": "http://localhost:8000/home", + "paths_hogql_expression": "event", + "edge_limit": 50, + "min_edge_weight": 10, + "max_edge_weight": 20, + "local_path_cleaning_filters": [{"alias": "merchant", "regex": "\\/merchant\\/\\d+\\/dashboard$"}], + "path_replacements": True, + "exclude_events": ["http://localhost:8000/events"], + "step_limit": 5, + "path_groupings": ["/merchant/*/payment"], + "funnel_paths": "funnel_path_between_steps", + "funnel_filter": { + "insight": "FUNNELS", + "events": [ + {"type": "events", "id": "$pageview", "order": 0, "name": "$pageview", "math": "total"}, + {"type": "events", "id": None, "order": 1, "math": "total"}, + ], + "funnel_viz_type": "steps", + "exclusions": [], + "filter_test_accounts": True, + "funnel_step": 2, + }, + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.pathsFilter, + PathsFilter( + include_event_types=[PathType.field_pageview, PathType.hogql], + paths_hogql_expression="event", + start_point="http://localhost:8000/events", + end_point="http://localhost:8000/home", + edge_limit=50, + min_edge_weight=10, + max_edge_weight=20, + local_path_cleaning_filters=[ + PathCleaningFilter(alias="merchant", regex="\\/merchant\\/\\d+\\/dashboard$") + ], + path_replacements=True, + exclude_events=["http://localhost:8000/events"], + step_limit=5, + path_groupings=["/merchant/*/payment"], + funnel_paths=FunnelPathType.funnel_path_between_steps, + funnel_filter={ + "insight": "FUNNELS", + "events": [ + {"type": "events", "id": "$pageview", "order": 0, "name": "$pageview", "math": "total"}, + {"type": "events", "id": None, "order": 1, "math": "total"}, + ], + "funnel_viz_type": "steps", + "exclusions": [], + "filter_test_accounts": True, + "funnel_step": 2, + }, + ), + ) + + def test_stickiness_filter(self): + filter = LegacyStickinessFilter( + data={"insight": "STICKINESS", "compare": True, "shown_as": "Stickiness"}, team=self.team + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.stickinessFilter, + StickinessFilter(compare=True, shown_as=ShownAsValue.Stickiness), + ) + + def test_lifecycle_filter(self): + filter = LegacyFilter( + data={ + "insight": "LIFECYCLE", + "shown_as": "Lifecycle", + } + ) + + query = filter_to_query(filter) + + self.assertEqual( + query.lifecycleFilter, + LifecycleFilter( + shown_as=ShownAsValue.Lifecycle, + ), + ) diff --git a/posthog/models/filters/__init__.py b/posthog/models/filters/__init__.py index db19d8addc105..fa75a96fc7308 100644 --- a/posthog/models/filters/__init__.py +++ b/posthog/models/filters/__init__.py @@ -19,3 +19,5 @@ AnyFilter: TypeAlias = ( Filter | PathFilter | RetentionFilter | StickinessFilter | SessionRecordingsFilter | PropertiesTimelineFilter ) + +AnyInsightFilter: TypeAlias = Filter | PathFilter | RetentionFilter | StickinessFilter diff --git a/posthog/models/filters/filter.py b/posthog/models/filters/filter.py index e0549650981e6..816e1a846d7fe 100644 --- a/posthog/models/filters/filter.py +++ b/posthog/models/filters/filter.py @@ -1,5 +1,6 @@ from .base_filter import BaseFilter from .mixins.common import ( + AggregationAxisMixin, BreakdownMixin, BreakdownValueMixin, ClientQueryIdMixin, @@ -88,6 +89,7 @@ class Filter( UpdatedAfterMixin, ClientQueryIdMixin, SampleMixin, + AggregationAxisMixin, BaseFilter, ): """ diff --git a/posthog/models/filters/mixins/common.py b/posthog/models/filters/mixins/common.py index bbb727407c6be..b7303ea3e3ebf 100644 --- a/posthog/models/filters/mixins/common.py +++ b/posthog/models/filters/mixins/common.py @@ -592,3 +592,21 @@ def sampling_factor(self) -> Optional[float]: @include_dict def sampling_factor_to_dict(self): return {SAMPLING_FACTOR: self.sampling_factor or ""} + + +class AggregationAxisMixin(BaseParamMixin): + """ + Aggregation Axis. Only used frontend side. + """ + + @cached_property + def aggregation_axis_format(self) -> Optional[str]: + return self._data.get("aggregation_axis_format", None) + + @cached_property + def aggregation_axis_prefix(self) -> Optional[str]: + return self._data.get("aggregation_axis_prefix", None) + + @cached_property + def aggregation_axis_postfix(self) -> Optional[str]: + return self._data.get("aggregation_axis_postfix", None) diff --git a/posthog/models/filters/retention_filter.py b/posthog/models/filters/retention_filter.py index 0d9e1568c5d3d..cd767606a6dd1 100644 --- a/posthog/models/filters/retention_filter.py +++ b/posthog/models/filters/retention_filter.py @@ -45,7 +45,10 @@ class RetentionFilter( BaseFilter, ): def __init__(self, data: Dict[str, Any] = {}, request: Optional[Request] = None, **kwargs) -> None: - data["insight"] = INSIGHT_RETENTION + if data: + data["insight"] = INSIGHT_RETENTION + else: + data = {"insight": INSIGHT_RETENTION} super().__init__(data, request, **kwargs) @cached_property diff --git a/posthog/models/filters/stickiness_filter.py b/posthog/models/filters/stickiness_filter.py index 5327406c90b95..dbabdd5e6897a 100644 --- a/posthog/models/filters/stickiness_filter.py +++ b/posthog/models/filters/stickiness_filter.py @@ -4,6 +4,8 @@ from rest_framework.exceptions import ValidationError from rest_framework.request import Request +from posthog.constants import INSIGHT_STICKINESS + from .base_filter import BaseFilter from .mixins.common import ( ClientQueryIdMixin, @@ -54,6 +56,10 @@ class StickinessFilter( team: "Team" def __init__(self, data: Optional[Dict[str, Any]] = None, request: Optional[Request] = None, **kwargs) -> None: + if data: + data["insight"] = INSIGHT_STICKINESS + else: + data = {"insight": INSIGHT_STICKINESS} super().__init__(data, request, **kwargs) team: Optional["Team"] = kwargs.get("team", None) if not team: diff --git a/posthog/models/filters/test/test_stickiness_filter.py b/posthog/models/filters/test/test_stickiness_filter.py index b2b5d70bda46d..85ef23a2e83be 100644 --- a/posthog/models/filters/test/test_stickiness_filter.py +++ b/posthog/models/filters/test/test_stickiness_filter.py @@ -37,7 +37,7 @@ def test_filter_properties(self): "properties": {}, } ], - "insight": "TRENDS", + "insight": "STICKINESS", "interval": "month", "sampling_factor": 0.1, }, diff --git a/posthog/schema.py b/posthog/schema.py index b80f0163d3477..f54f5ee12e9c6 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -124,13 +124,10 @@ class ElementType(BaseModel): class EmptyPropertyFilter(BaseModel): + pass model_config = ConfigDict( extra="forbid", ) - key: Optional[Any] = None - operator: Optional[Any] = None - type: Optional[Any] = None - value: Optional[Any] = None class EntityType(str, Enum): @@ -185,18 +182,7 @@ class FunnelConversionWindowTimeUnit(str, Enum): month = "month" -class FunnelLayout(str, Enum): - horizontal = "horizontal" - vertical = "vertical" - - -class FunnelPathType(str, Enum): - funnel_path_before_step = "funnel_path_before_step" - funnel_path_between_steps = "funnel_path_between_steps" - funnel_path_after_step = "funnel_path_after_step" - - -class FunnelStepRangeEntityFilter(BaseModel): +class FunnelExclusion(BaseModel): model_config = ConfigDict( extra="forbid", ) @@ -210,6 +196,17 @@ class FunnelStepRangeEntityFilter(BaseModel): type: Optional[EntityType] = None +class FunnelLayout(str, Enum): + horizontal = "horizontal" + vertical = "vertical" + + +class FunnelPathType(str, Enum): + funnel_path_before_step = "funnel_path_before_step" + funnel_path_between_steps = "funnel_path_between_steps" + funnel_path_after_step = "funnel_path_after_step" + + class FunnelStepReference(str, Enum): total = "total" previous = "previous" @@ -554,7 +551,7 @@ class FunnelsFilter(BaseModel): breakdown_attribution_value: Optional[float] = None drop_off: Optional[bool] = None entrance_period_start: Optional[str] = None - exclusions: Optional[List[FunnelStepRangeEntityFilter]] = None + exclusions: Optional[List[FunnelExclusion]] = None funnel_advanced: Optional[bool] = None funnel_aggregate_by_hogql: Optional[str] = None funnel_correlation_person_converted: Optional[FunnelCorrelationPersonConverted] = None